From c46092f202598b35b6dfd343c1e227c624d7c248 Mon Sep 17 00:00:00 2001 From: nguyenm7 <13559011+nguyenm7@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:09:02 +0000 Subject: [PATCH] fix(skills-catalog): validate server-supplied discovery URLs before fetching The authoring workflow told the reader to curl two URLs an unauthenticated MCP endpoint chooses: resource_metadata out of the WWW-Authenticate challenge, and the issuer out of the document that URL returns. A hostile endpoint could point either at 169.254.169.254, at a loopback service, or at a public hostname whose DNS record is 127.0.0.1, and a bare curl would go there with the author's network position. references/safe-discovery.md adds safe_curl, and step 2 now uses it for both fetches. It is an allowlist, not a denylist, and it fails closed: https only, no userinfo, port 443 only, and every resolved address checked against loopback, private, CGNAT, link-local, unique-local, multicast, benchmarking, documentation and reserved space. IPv4-mapped, NAT64 and 6to4 forms are unwrapped and the embedded IPv4 address is checked under the IPv4 rules. One bad answer in a round-robin set refuses the whole host. Two details that decide whether this works rather than looks like it does. The fetch pins --resolve to exactly the addresses that were validated, so a second DNS answer cannot move the request between the check and the connection. And no redirect is ever followed: a 3xx is reported and refused, and going there means re-running safe_curl on the Location so it is validated on its own merits. Executed against adversarial metadata rather than a URL list: a hostile challenge and two hostile protected-resource documents, parsed the way step 2 says to parse them. localtest.me is the case a string check cannot catch -- an ordinary public hostname with a real public record pointing at loopback. Twenty refusal cases, one per class; the redirect refusal against a live 301; and the documented flow still returns Enterpret's 401 challenge and protected-resource document unchanged. The two scripts in the document were extracted and diffed against the tested copies: byte-identical. Also the four Enterpret acceptance findings that belong in this package: - Every pinned count in catalog-contract.md was stale. APP_STORE_DEFINITIONS went 46 -> 56 and SELF_SERVE_MCP_CANDIDATES 43 -> 48 since the file was written, so following it wrote a failing assertion. The counts are now a command that reads them out of the checkout in front of you. - Every line-number citation in both skills is gone, in favour of a file plus a symbol to grep. Four playbook citations landed on unrelated text; every claim behind them survives and was re-read at 18dac1e1, which both SKILL.md files now record. - The visibility contract has three states, not two. Hiding a slug is not withholding it -- a hidden slug is still reachable by direct URL or slug lookup. availability: { available: false, reason } is the state that refuses setup, and it is where a connector authored without a live provider proof belongs. - Installed catalog skills materialize as connect-agent-tools--, so the three bare-slug sibling paths were dead for any installed reader. They now name the skill, with the command to resolve the directory. skills-catalog: manifest regenerated (19 skills), validate clean, 20 tests. Both packages still derive markdown_only; descriptions 291 and 293. Server company-skills suites 161/161 against the regenerated manifest. Seventeen packaged relative links, none broken, no private references. Co-Authored-By: Paperclip --- doc/connections/ENTERPRET-ACCEPTANCE.md | 3 +- .../author-connector-definition/SKILL.md | 102 +++-- .../references/catalog-contract.md | 119 ++++-- .../references/offline-verification.md | 4 +- .../references/safe-discovery.md | 377 ++++++++++++++++++ .../connect-agent-tools/SKILL.md | 72 ++-- .../references/deployment-support-matrix.md | 44 +- .../references/verification-log.md | 72 ++-- .../skills-catalog/generated/catalog.json | 36 +- 9 files changed, 662 insertions(+), 167 deletions(-) create mode 100644 packages/skills-catalog/catalog/optional/software-development/author-connector-definition/references/safe-discovery.md diff --git a/doc/connections/ENTERPRET-ACCEPTANCE.md b/doc/connections/ENTERPRET-ACCEPTANCE.md index cb4d4bffca..c3c5c0d58a 100644 --- a/doc/connections/ENTERPRET-ACCEPTANCE.md +++ b/doc/connections/ENTERPRET-ACCEPTANCE.md @@ -252,7 +252,8 @@ that is precisely the protection a pasted URL lacks. ## 4. The nine production-validation scenarios -Runbook matrix at `CONNECTOR-PLAYBOOK.md:1445-1455`. **Every row that names +Runbook matrix at `CONNECTOR-PLAYBOOK.md`, **Step 9: Align With Production +Validation**. **Every row that names Enterpret itself is `not run`, for one reason: there is no authorized credential, and registration, consent and tool calls are out of bounds.** The mirror result is recorded beside it, because the mirror exercises Paperclip's diff --git a/packages/skills-catalog/catalog/optional/software-development/author-connector-definition/SKILL.md b/packages/skills-catalog/catalog/optional/software-development/author-connector-definition/SKILL.md index 3b48e5b994..ccf24fd2b4 100644 --- a/packages/skills-catalog/catalog/optional/software-development/author-connector-definition/SKILL.md +++ b/packages/skills-catalog/catalog/optional/software-development/author-connector-definition/SKILL.md @@ -31,12 +31,12 @@ slug allowlist, branding validation, the exact-count test assertions, and the offline verification loop. It does not own the process around them. **A catalog entry is a convenience layer, not a prerequisite.** The runbook is -explicit: an operator can connect any standards-compliant remote HTTP MCP server -from **Connect your own MCP server** or **Paste a config** with no Paperclip -code change at all, including servers that need browser sign-in -(`CONNECTOR-PLAYBOOK.md:23-26`). Check that the requester actually needs a -catalog entry before spending a single edit here. `connect-agent-tools` owns -that decision. +explicit: an operator can connect any standards-compliant remote HTTP MCP +server from **Connect your own MCP server** or **Paste a config** with no +Paperclip code change at all, including servers that need browser sign-in +(`CONNECTOR-PLAYBOOK.md`, opening section). Check that the requester actually +needs a catalog entry before spending a single edit here. +`connect-agent-tools` owns that decision. **Self-hosted first.** Everything this skill produces is authored and verified against an App checkout and an instance you run yourself. Phases 8 and 9 of the @@ -46,6 +46,12 @@ is genuinely Cloud-gated — the curated `platform_shared` Paperclip-managed OAu profile is the one current case — record it as `unsupported` for self-hosted rather than treating Cloud as the baseline. +**How this skill cites the product.** A file and a symbol you can grep for, +never a line number: line numbers drift between the read and the reader, and +this skill has already been caught citing four that had moved. Every source and +runbook citation below was re-read at App commit `18dac1e1` (24 September 2026). +If the commit in front of you disagrees, the commit wins — record the drift. + ## Use This When Trigger on a request that names a provider and asks for a catalog connector, @@ -69,13 +75,14 @@ Hand the request to the right owner instead: | "The provider has no MCP server" | Out of scope. A manifest cannot wrap an arbitrary REST API, run an arbitrary local command, or register an OAuth client in a console Paperclip does not control. Report the boundary. | | "Ship it" / "install it" / "release it" | Not authorized here. See **Authorization boundaries**. | -PR #13675 made the runbook self-contained so that "contributors can implement a -connector without access to an internal issue tracker", and removed the separate -private validation issue ("No separate private validation issue is required", -`CONNECTOR-PLAYBOOK.md:1443`). Treat `prepare-mcp-integration` as an internal -convenience for people working inside Paperclip Content, not as a gate a public -contributor must pass. When it does apply, that skill drives and calls this one -for the definition itself. +PR #13675 made the runbook self-contained so that "contributors can implement +a connector without access to an internal issue tracker", and removed the +separate private validation issue ("No separate private validation issue is +required", `CONNECTOR-PLAYBOOK.md`, **Step 9: Align With Production +Validation**). Treat `prepare-mcp-integration` as an internal convenience for +people working inside Paperclip Content, not as a gate a public contributor +must pass. When it does apply, that skill drives and calls this one for the +definition itself. ## Required Inputs @@ -90,7 +97,13 @@ Collect these before step 1. Ask only for what you cannot determine safely. the runbook no longer requires a private research approval. Inside Paperclip Content, that decision is `prepare-mcp-integration`'s gate; if it applies and has not been taken, stop and route there. -4. **Intended visibility**: store-visible, or connectable-but-hidden. +4. **Intended visibility**, which is a three-way choice, not a toggle: + store-visible; connectable but unlisted (`APP_STORE_HIDDEN_SLUGS` — still + reachable by direct URL or slug, so hiding is not withholding); or withheld + (`availability: { available: false, reason }`, which refuses setup and + renders the reason instead of a Connect action). A connector authored + without a live provider proof belongs in the third state, not the second. + See the visibility chain in `references/catalog-contract.md`. 5. **Location of the ingestion corpus** (`paperclip-content`), or the value for `PAPERCLIP_CONTENT_TEMPLATES`. This corpus is in a non-public repository; the generator refuses to run without it. If you do not have it, this skill's @@ -151,8 +164,8 @@ Read from the recorded commit, not from memory and not from a stale worktree: instructions, provider handoffs, personal identity linking, the optional message test, and management states. Do not apply Slack's steps or credential types to a provider that does not work that way, and do not skip it when they - apply. `connect-agent-tools/references/chat-and-email.md` is the short routing - layer over it. + apply. The `connect-agent-tools` skill's `references/chat-and-email.md` is + the short routing layer over it. - `doc/connections/README.md` — the identity/connections boundary. Every connector is a plane P2 resource credential, never a sign-in authenticator. Sign-in tokens are never reused as resource tokens; `id.paperclip.ing` never @@ -165,6 +178,20 @@ Read from the recorded commit, not from memory and not from a stale worktree: - `packages/shared/src/app-definitions.test.ts` — the assertions your change must satisfy. +**Finding the sibling skill's files.** Installed catalog skills do not +materialize under their bare slug. A run sees +`connect-agent-tools--/`, where the suffix is generated per package, so +`../connect-agent-tools/references/…` resolves to nothing and a relative link +written that way is dead the moment the skill is installed rather than read out +of a checkout. Resolve the directory before you read it: + +```sh +find "$(dirname "$PWD")" -maxdepth 1 -name 'connect-agent-tools*' -type d +``` + +Inside an App checkout the bare name is correct; both forms appear, and only +one of them is the one an installed reader has. + Then read `references/catalog-contract.md` in this skill for the file-by-file map and the assertions that carry exact counts. Treat that reference as a starting index, not as a substitute: if it disagrees with the commit you read, @@ -179,18 +206,36 @@ do not invent hosted OAuth for a same-machine, unauthenticated server. If the current transport or egress policy cannot support it, report the runtime gap. For OAuth providers, these unauthenticated requests inspect the transport and -auth axes. Skip OAuth discovery for a provider that does not use OAuth: +auth axes. Skip OAuth discovery for a provider that does not use OAuth. + +**Do not `curl` these URLs directly.** After the first request, the server +picks where you go next: `resource_metadata` comes out of its challenge, and +the issuer comes out of the document that URL returns. A hostile endpoint can +point either at cloud metadata at `169.254.169.254`, at a loopback service, or +at a public hostname whose DNS record is `127.0.0.1` — and a bare `curl` will +go there with your network position. Set up `safe_curl` from +[references/safe-discovery.md](references/safe-discovery.md) first. It +validates scheme, port, userinfo and every resolved address before anything is +sent, pins the connection to the addresses it validated so DNS cannot move the +request afterwards, and refuses redirects. Refusal is exit 2 and means nothing +left the machine. ```sh -curl -s -i -X POST "$SERVER_URL" \ +source ./safe-fetch.sh # see references/safe-discovery.md + +safe_curl "$SERVER_URL" -X POST \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' # Read resource_metadata out of the WWW-Authenticate challenge, then: -curl -s "$RESOURCE_METADATA_URL" # RFC 9728 -> authorization_servers -curl -s "$ISSUER/.well-known/oauth-authorization-server" # RFC 8414 +safe_curl "$RESOURCE_METADATA_URL" # RFC 9728 -> authorization_servers +safe_curl "$ISSUER/.well-known/oauth-authorization-server" # RFC 8414 ``` +A refusal is a finding, not an obstacle to route around. Record the URL the +provider supplied and what it resolved to, and escalate it — a catalog +connector whose discovery points inside the authoring network does not ship. + Record, with the date you read it: the endpoint and its trailing-slash behaviour; the unauthenticated status and challenge; the protected-resource and authorization-server metadata URLs; the exact issuer; authorize, token, @@ -273,14 +318,16 @@ Then split your evidence into three explicit buckets and never blur them: Then answer the runbook's nine production-validation scenarios — setup and consent, authentication, catalog and configuration, allowed execution, denied execution, runtime delivery, refresh and recovery, revoke and reconnect, -activity and secret handling (`CONNECTOR-PLAYBOOK.md:1445-1455`) — with -**pass / fail / not run / not applicable** and a reason for the last two, plus -environment, method key, commit, and accessible redacted evidence. Offline -authoring legitimately produces a column of "not run"; what it must never -produce is a blank or an optimistic one. +activity and secret handling (`CONNECTOR-PLAYBOOK.md`, **Step 9: Align With +Production Validation** — the scenario table) — with **pass / fail / not run / +not applicable** and a reason for the last two, plus environment, method key, +commit, and accessible redacted evidence. Offline authoring legitimately +produces a column of "not run"; what it must never produce is a blank or an +optimistic one. Report those results per deployment, self-hosted first, using the four labels -in `connect-agent-tools/references/deployment-support-matrix.md`. A pass on a +in the `connect-agent-tools` skill's `references/deployment-support-matrix.md`. +A pass on a self-hosted instance makes that row `verified` for self-hosted and leaves Cloud `untested`; the reverse is equally true. @@ -340,7 +387,8 @@ Deliver exactly this, and nothing that implies more: 5. **A deployment support matrix** — self-hosted same-machine, self-hosted server/VPS, and Cloud, each capability labelled `verified`, `untested`, `unsupported` or `deferred` with its evidence or named follow-up owner. Use - `connect-agent-tools/references/deployment-support-matrix.md` as the shape. + the `connect-agent-tools` skill's `references/deployment-support-matrix.md` + as the shape. 6. **Remaining gaps** — unprobed constraints, the account-bound lifecycle, and anything a reviewer must authorize before release. 7. **An explicit statement** of what did not happen: no push, no PR, no deploy, diff --git a/packages/skills-catalog/catalog/optional/software-development/author-connector-definition/references/catalog-contract.md b/packages/skills-catalog/catalog/optional/software-development/author-connector-definition/references/catalog-contract.md index d5b280aa11..75aeb22319 100644 --- a/packages/skills-catalog/catalog/optional/software-development/author-connector-definition/references/catalog-contract.md +++ b/packages/skills-catalog/catalog/optional/software-development/author-connector-definition/references/catalog-contract.md @@ -2,22 +2,27 @@ Index of the files and assertions a catalog-connector change has to satisfy. -**Verified against Paperclip App commit `e558f25e` (15 September 2026), by -re-reading each cited file and by running the command ladder below through the -isolated harness.** Previously verified at `728f7185` (14 September 2026); every -claim below survived that move, but several line numbers did not, so this -revision cites symbols and landmarks you can grep for and keeps line numbers -only where they are load-bearing. If the commit you read disagrees, the commit -wins — and record the drift in your report so this file gets corrected. +**Verified against Paperclip App commit `18dac1e1` (24 September 2026), by +re-reading every cited file at that commit and running the command ladder below +through the isolated harness.** + +**How this file cites things.** A file and a symbol, never a line number, and +never a transcribed count. Both were tried and both rotted: between `728f7185` +and `e558f25e` — nine days — several line numbers moved, and between `e558f25e` +and `18dac1e1` the store-visible count went from 46 to 56 and the candidate +count from 43 to 48. A reader who trusted a printed number would have written a +failing assertion. So the counts below are commands that read the number out of +the repository in front of you. Run them. If a symbol has moved or gone, the +commit wins — record the drift in your report so this file gets corrected. ## Files a minimal store-visible connector touches | File | Role | Editable? | | --- | --- | --- | -| `scripts/ingest-app-definitions.mjs` | Human-authored provider source. The `apps` array starts at line 180; the tuple mapper follows it (grep for `schemaVersion: 1,` inside the `.map((`, around line 795). | Yes — this is the source. | +| `scripts/ingest-app-definitions.mjs` | Human-authored provider source. The `apps` array and, after it, the tuple mapper — grep `const apps = [` and `schemaVersion: 1,`. | Yes — this is the source. | | `ui/public/brands/apps/.svg` | Official mark. | Yes. | | `ui/public/brands/apps/manifest.json` | Branding provenance: slug, provider name, `catalogVisible`, `localAsset`, optional `darkAsset`, optional `aliases`. | Yes. | -| `packages/shared/src/app-definitions.ts` | `CONNECTABLE_APP_SLUGS` (line 6) and `APP_STORE_HIDDEN_SLUGS` (line 44). | Yes. | +| `packages/shared/src/app-definitions.ts` | `CONNECTABLE_APP_SLUGS` and `APP_STORE_HIDDEN_SLUGS`. | Yes. | | `packages/shared/src/types/app-definition.ts` | The field contract the generator output has to satisfy. | Read-only for authoring. | | `packages/shared/src/app-definitions/.json` | Generated definition. | **Generated** — see the exception below. | | `packages/shared/src/app-definitions.generated.ts` | Generated positional registry. | **Generated.** | @@ -31,7 +36,7 @@ required for a store-visible provider. ## The generated-file exception -`scripts/ingest-app-definitions.mjs:1379-1396` (`reviewedGoogleSlugs`) reads nine +`scripts/ingest-app-definitions.mjs` (grep `reviewedGoogleSlugs`) reads nine definitions back from the output directory and re-emits them verbatim: ```js @@ -48,15 +53,15 @@ change the next run reverts. ## Generator preconditions -- **Corpus.** `scripts/ingest-app-definitions.mjs:4-9` resolves +- **Corpus.** `scripts/ingest-app-definitions.mjs` resolves `PAPERCLIP_CONTENT_TEMPLATES`, defaulting to - `../../paperclip-content/research/connections/vercel/templates`. Line 1541-1542 - throws `Expected 99 captures, found N` unless exactly 99 `.md` files - (excluding `INDEX.md`) are present. A new provider needs no capture of its - own; the corpus still has to be complete. The corpus lives in the - non-public `paperclip-content` repository: if you do not have it, this whole - path is closed to you and you report that rather than stubbing the guard out. -- **Branding.** `brandingFor` (declared line 20, throws line 29) throws + `../../paperclip-content/research/connections/vercel/templates`. It throws + `Expected 99 captures, found N` unless exactly 99 `.md` files (excluding + `INDEX.md`) are present. A new provider needs no capture of its own; the + corpus still has to be complete. The corpus lives in the non-public + `paperclip-content` repository: if you do not have it, this whole path is + closed to you and you report that rather than stubbing the guard out. +- **Branding.** `brandingFor` throws `: missing local branding provenance` unless the slug has a manifest row (only `oauth-generic` and `api-key-generic` are exempt). Branding precedes generation. @@ -73,7 +78,7 @@ change the next run reverts. `content`, `data`, `developer`, `productivity`, `other`. - The 5th element (`domain`) is discarded by the mapper. `docsUrl` must go in `extra`, or it is only backfilled for providers that also appear in the - research ledger (`ingest-app-definitions.mjs:1315`). + research ledger (`ingest-app-definitions.mjs`, grep `docsUrl`). - `method(key, transport, auth, defaults, riskTier, guidanceMd, extra)` fills `ownershipModes` as `["customer", "dcr"]` for `oauth` and `["customer"]` otherwise, plus a default `whenToUse`. @@ -89,28 +94,70 @@ APP_DEFINITIONS (generated, all providers) ``` `GET /api/companies/:companyId/tools/apps` returns `APP_STORE_DEFINITIONS` -directly (`server/src/routes/tool-access.ts:831`). There is no company-scoped -definition store, and `connectToolAppSchema` accepts a `galleryKey` or a `link` -and nothing else (`packages/shared/src/validators/tool-access.ts:472-474`). A -catalog connector is therefore always a shared-source change. +directly (`server/src/routes/tool-access.ts`, grep `tools/apps`). There is no +company-scoped definition store, and `connectToolAppSchema` accepts a +`galleryKey` or a `link` and nothing else (`connectToolAppSchema` in +`packages/shared/src/validators/tool-access.ts`). A catalog connector is +therefore always a shared-source change. + +### Three states, not two + +The chain above is about *listing*. It is not the whole visibility contract, +and reading it as though it were is the mistake this file used to invite. + +| State | How you set it | What a user can do | +| --- | --- | --- | +| **Store-visible** | In `CONNECTABLE_APP_SLUGS`, not in `APP_STORE_HIDDEN_SLUGS` | Sees the card, connects. | +| **Connectable but unlisted** | Add the slug to `APP_STORE_HIDDEN_SLUGS` | Does **not** see the card in the gallery, and can still connect by direct URL or by slug lookup. Hiding is not withholding. | +| **Withheld** | `availability: { available: false, reason }` on the definition | Cannot connect. The reason renders where the Connect action would be. | + +The third one is the one to reach for when a connector is authored but not yet +proven against the provider — offline authoring legitimately produces a column +of "not run", and a definition in that state must not offer a Connect button +that cannot work. + +`available: false` is enforced server-side, not just in the UI. Grep these +before trusting the label: + +```sh +git grep -n 'availability?\.available === false' server/src ui/src +``` + +At `18dac1e1` that is refused by the metadata preflight (`notFound("App not +found")` in `server/src/services/tool-access.ts`), filtered out of agent +connection intents (`server/src/services/connection-intents.ts`), and rendered +as a disabled card with the reason in `ConnectionSetupFlow.tsx` and +`Browse.tsx`. ## Assertions with exact counts or sets These fail on any addition. Update them deliberately. -| Assertion | Location | What breaks | +Read the current numbers before you touch anything. This prints every count +the catalog pins, out of the checkout in front of you: + +```sh +grep -nE 'toHaveLength\(|verifiedAt\)\.toBe' packages/shared/src/app-definitions.test.ts +``` + +At `18dac1e1` that reports `APP_STORE_DEFINITIONS` 56, `SELF_SERVE_MCP_CANDIDATES` +48, `SELF_SERVE_MCP_RESEARCH.entries` 51, `verifiedAt` `"2026-08-26"`. Those four +are here to show you what the output looks like, not to be copied into an edit — +three of the four have already changed once since this file was written. + +| Assertion | Where | What breaks | | --- | --- | --- | -| `expect(APP_STORE_DEFINITIONS).toHaveLength(46)` | `app-definitions.test.ts:689`, in `it("withholds unverified and reserved providers …")` | Any new store-visible provider. Bump the count. | -| `catalogVisible` manifest set must equal the store-visible definition set, and asset paths must equal `branding` values | same test, from line 716 (`manifest.providers.filter(… catalogVisible)`) | A manifest row without a definition, or the reverse. Also enforces PNG ≥ 128×128 and rejects script/`foreignObject`/event handlers in SVG. | -| Required non-advanced tenant/extension fields enumerated in a short allowlist | `app-definitions.test.ts` around line 905 (`field.required && field.advanced !== true && !field.hidden`) | Any visible required field on the default path. Prefer making the field optional or advanced with a default. | -| `expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(43)` | `app-definitions.test.ts:280` | Adding a provider to the research ledger. | -| Ledger entry count with a fixed `verifiedAt` (`"2026-08-26"` at this commit) | `app-definitions.test.ts:434-435` | Same. Also requires HTTPS `docsUrl`/`serverUrl`, a non-empty `authMode`, a prerequisite longer than 10 characters, and a valid tier. | -| `APP_STORE_HIDDEN_SLUGS` exact sorted list | `app-definitions.test.ts:666-688` | Hiding a provider. Hidden slugs must still be connectable. | -| Method and field invariants across the whole catalog | `it("enforces method and field invariants")`, `app-definitions.test.ts:961` | A missing `keyPlacement`, empty `ownershipModes`, or a required credential field with no placeholder. | +| `expect(APP_STORE_DEFINITIONS).toHaveLength(N)` | `app-definitions.test.ts`, in `it("withholds unverified and reserved providers …")` | Any new store-visible provider. Set the count to what the command above reports, plus one. | +| `catalogVisible` manifest set must equal the store-visible definition set, and asset paths must equal `branding` values | same test — grep `manifest.providers.filter` | A manifest row without a definition, or the reverse. Also enforces PNG ≥ 128×128 and rejects script/`foreignObject`/event handlers in SVG. | +| Required non-advanced tenant/extension fields enumerated in a short allowlist | `app-definitions.test.ts` — grep `field.advanced !== true` | Any visible required field on the default path. Prefer making the field optional or advanced with a default. | +| `expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(N)` | `app-definitions.test.ts` | Adding a provider to the research ledger. | +| Ledger entry count with a fixed `verifiedAt` | `app-definitions.test.ts` — grep `SELF_SERVE_MCP_RESEARCH` | Same. Also requires HTTPS `docsUrl`/`serverUrl`, a non-empty `authMode`, a prerequisite longer than 10 characters, and a valid tier. | +| `APP_STORE_HIDDEN_SLUGS` exact sorted list | `app-definitions.test.ts` — grep `APP_STORE_HIDDEN_SLUGS` | Hiding a provider. Hidden slugs must still be connectable. | +| Method and field invariants across the whole catalog | `it("enforces method and field invariants")` | A missing `keyPlacement`, empty `ownershipModes`, or a required credential field with no placeholder. | The provider-slug membership check above those uses `arrayContaining`, so an -addition does not break it. Confirmed at `e558f25e`: adding one store-visible -provider failed exactly one assertion, the count at line 689. +addition does not break it. Measured at `e558f25e`: adding one store-visible +provider failed exactly one assertion, the `APP_STORE_DEFINITIONS` count. ## Network and deployment guard @@ -119,7 +166,7 @@ is *not* both authenticated and publicly exposed; link-local egress is denied in every mode. ```ts -// server/src/services/tool-access.ts:3175-3180 (and tool-gateway.ts:3132-3137) +// allowPrivateRemoteEndpoints, in server/src/services/tool-access.ts function allowPrivateRemoteEndpoints() { return ( options.deploymentMode !== "authenticated" || @@ -130,7 +177,7 @@ function allowPrivateRemoteEndpoints() { `DEPLOYMENT_MODES` is `["local_trusted", "authenticated"]` and `DEPLOYMENT_EXPOSURES` is `["private", "public"]` -(`packages/shared/src/constants.ts:4-8`). The check runs inside +(`packages/shared/src/constants.ts`). The check runs inside `guardedRemoteHttpFetch` at dial time rather than as a standalone pre-flight, which is what closes the DNS-rebinding window — so a same-machine desktop MCP endpoint is a local-deployment capability, not a configuration flag to widen. @@ -163,7 +210,7 @@ manifest does nothing. | Transport | Manifest-only? | Boundary | | --- | --- | --- | | `mcp_remote` | Yes | First-class: discovery, health, catalog, gateway, OAuth, credential projection. | -| `local_stdio` | Only via a registered template | Reported unsupported outside `local_trusted` mode or a configured trusted runtime host (`server/src/services/tool-access.ts:4788-4789`). Never put a bare command in a definition. | +| `local_stdio` | Only via a registered template | Reported unsupported outside `local_trusted` mode or a configured trusted runtime host (`server/src/services/tool-access.ts`, grep `local_stdio`). Never put a bare command in a definition. | | `rest_api` | No | Not exposed through the connected MCP gateway. Needs an execution adapter first. | `api_key` is an authentication mode, not a transport. Most API-key catalog diff --git a/packages/skills-catalog/catalog/optional/software-development/author-connector-definition/references/offline-verification.md b/packages/skills-catalog/catalog/optional/software-development/author-connector-definition/references/offline-verification.md index 0c34b02034..39fa45367d 100644 --- a/packages/skills-catalog/catalog/optional/software-development/author-connector-definition/references/offline-verification.md +++ b/packages/skills-catalog/catalog/optional/software-development/author-connector-definition/references/offline-verification.md @@ -13,9 +13,9 @@ checkout is untouched. This used to ship as `scripts/make-harness.sh` inside the skill. It is inlined here on purpose: a skill package containing anything under `scripts/` derives the `scripts_executables` trust level (`deriveTrustLevel` in -`packages/skills-catalog/src/catalog-builder.ts:763-767`), and the shipped +`packages/skills-catalog/src/catalog-builder.ts`), and the shipped catalog pins that set to exactly one key -(`packages/skills-catalog/src/shipped-catalog.test.ts:132-136`). Carrying the +(`packages/skills-catalog/src/shipped-catalog.test.ts`). Carrying the recipe as documentation keeps this package `markdown_only` and installable without an audit-allowlist change. Save it to a file yourself and `chmod +x` it if you prefer to run it as a script. diff --git a/packages/skills-catalog/catalog/optional/software-development/author-connector-definition/references/safe-discovery.md b/packages/skills-catalog/catalog/optional/software-development/author-connector-definition/references/safe-discovery.md new file mode 100644 index 0000000000..1b2624c6ba --- /dev/null +++ b/packages/skills-catalog/catalog/optional/software-development/author-connector-definition/references/safe-discovery.md @@ -0,0 +1,377 @@ +# Safe discovery fetches + +Step 2 of this skill reads OAuth metadata out of an MCP server you do not +control and have not authenticated to. Two of the URLs you follow are chosen by +that server: + +- `resource_metadata` in the `WWW-Authenticate` challenge (RFC 9728), and +- each entry of `authorization_servers` in the protected-resource document, + which becomes the issuer you build the RFC 8414 URL from. + +Both arrive before any trust is established. A hostile or compromised endpoint +can point either one at `http://169.254.169.254/latest/meta-data/`, at a service +on `127.0.0.1`, at a name it published in public DNS with a loopback `A` record, +or at a host that answers the resolver honestly once and answers again with an +internal address a moment later. A plain `curl` of those values turns the +authoring machine into the attacker's HTTP client, inside whatever network the +author happens to be on. + +So the two fetches go through `safe_curl` below instead of bare `curl`. It +validates the destination *before* anything is sent, pins the connection to the +addresses it validated, and follows no redirect. + +## The rules + +| Input | What happens | +| --- | --- | +| Any scheme other than `https:` | Refused. It is an allowlist, so `http:`, `file:`, `ftp:`, `gopher:` and `data:` all land here without being enumerated. | +| A URL with userinfo (`https://a:b@host/`) | Refused. Credentials never belong on an unauthenticated discovery fetch, and userinfo is the classic way to make a host look like something it is not. | +| Any port other than 443 | Refused. A provider that genuinely publishes discovery on another port is a gap to record and escalate, not a default to widen. | +| A host that resolves to loopback, private, CGNAT, link-local, unique-local, multicast, benchmarking, documentation or reserved space | Refused, and the reason names the address. This is on resolved addresses, so a public DNS name with a `127.0.0.1` record is caught. | +| An IPv4-mapped (`::ffff:a9fe:a9fe`), NAT64 (`64:ff9b::`) or 6to4 (`2002::`) address wrapping a blocked IPv4 address | Refused. The embedded address is extracted and run through the IPv4 rules. | +| A host with several `A`/`AAAA` records where **one** is blocked | Refused. One bad answer in a round-robin set refuses the whole host. | +| Anything resolvable and allowed | Fetched with `--resolve` pinned to exactly the addresses that were validated, so a second DNS answer cannot move the request after the check. | +| Any `3xx` response | Refused. The redirect is reported and not followed. To go there you re-run `safe_curl` on the `Location`, which validates it on its own merits. | +| A response over 1 MiB, or 15 seconds | Refused by `--max-filesize` / `--max-time`. | + +Refusal is exit code 2 and means **nothing was sent**. The guard resolves names +but never opens a connection to the target. + +### What this does not protect against + +Say these out loud rather than letting the guard imply more than it does. + +- **A publicly routable host that is still internal to you.** Split-horizon DNS, + a corporate proxy, or a VPN can make a globally-addressable answer reach + something private. Run discovery from a network position with no privileged + internal access; the guard cannot see your routing table. +- **A lying resolver.** The guard checks what `getaddrinfo` returns. If the + resolver itself is hostile, it can return an allowed address that fronts an + internal service. +- **The provider being malicious at the application layer.** Refusing internal + destinations says nothing about whether the metadata itself is honest. The + `issuer` cross-check in Step 2 — discard an authorization-server document + whose `issuer` disagrees with the issuer you built the URL from — is a + separate control and still required. +- **Anything after discovery.** This covers the two unauthenticated metadata + fetches. Registration, consent and token exchange run through the product's + own guarded path, not through this recipe. + +## Why it is inlined here + +Same reason as `offline-verification.md`: a skill package with anything under +`scripts/` derives the `scripts_executables` trust level +(`deriveTrustLevel` in `packages/skills-catalog/src/catalog-builder.ts`), and +the shipped catalog pins that set to one key. Carrying the guard as +documentation keeps this package `markdown_only` and installable with no +audit-allowlist change. Save both files yourself; the Node file needs no +`chmod`. + +## The guard + +Save as `mcp-discovery-guard.mjs`. It requires Node 18 or newer and no +dependencies. It prints `ALLOW ` or +`REFUSE `, exiting 0 or 2. + +
+mcp-discovery-guard.mjs + +```javascript +// Resolve an untrusted discovery URL and decide whether it may be fetched. +// Prints "ALLOW " or "REFUSE ", and +// exits 0 or 2. It never opens a connection to the target itself. +import { isIP } from "node:net"; +import { lookup } from "node:dns/promises"; + +const refuse = (why) => { + process.stdout.write(`REFUSE ${why}\n`); + process.exit(2); +}; + +const raw = process.argv[2]; +if (!raw) refuse("no URL given"); + +let url; +try { + url = new URL(raw); +} catch { + refuse(`not a URL: ${raw}`); +} +// Scheme allowlist, not a denylist: file:, gopher:, ftp:, data: and plain +// http: all land here. +if (url.protocol !== "https:") refuse(`scheme is not https: ${url.protocol}`); +if (url.username || url.password) refuse("URL carries userinfo"); +const port = url.port === "" ? "443" : url.port; +if (port !== "443") refuse(`port is not 443: ${port}`); + +const host = url.hostname.replace(/^\[|\]$/g, ""); +if (!host) refuse("URL has no host"); + +const v4Blocks = [ + ["0.0.0.0", 8, "this-network"], + ["10.0.0.0", 8, "private"], + ["100.64.0.0", 10, "carrier-grade NAT"], + ["127.0.0.0", 8, "loopback"], + ["169.254.0.0", 16, "link-local / cloud metadata"], + ["172.16.0.0", 12, "private"], + ["192.0.0.0", 24, "IETF protocol assignments"], + ["192.0.2.0", 24, "documentation"], + ["192.88.99.0", 24, "6to4 relay anycast"], + ["192.168.0.0", 16, "private"], + ["198.18.0.0", 15, "benchmarking"], + ["198.51.100.0", 24, "documentation"], + ["203.0.113.0", 24, "documentation"], + ["224.0.0.0", 4, "multicast"], + ["240.0.0.0", 4, "reserved / broadcast"], +]; + +const v4ToInt = (a) => + a.split(".").reduce((acc, oct) => acc * 256 + Number(oct), 0); + +const classifyV4 = (addr) => { + const n = v4ToInt(addr); + for (const [base, bits, label] of v4Blocks) { + const mask = bits === 0 ? 0 : (-1 << (32 - bits)) >>> 0; + if ((n & mask) >>> 0 === (v4ToInt(base) & mask) >>> 0) return label; + } + return null; +}; + +// Expand any IPv6 form to 16 bytes. +const v6Bytes = (addr) => { + let head = addr; + let tailV4 = null; + const lastColon = addr.lastIndexOf(":"); + const maybeV4 = addr.slice(lastColon + 1); + if (maybeV4.includes(".")) { + if (isIP(maybeV4) !== 4) return null; + tailV4 = maybeV4.split(".").map(Number); + head = addr.slice(0, lastColon + 1) + "0:0"; + } + const [left, right] = head.split("::"); + const parse = (part) => + part && part.length ? part.split(":").map((h) => parseInt(h, 16)) : []; + const l = parse(left); + const r = right === undefined ? [] : parse(right); + const groups = + right === undefined + ? l + : [...l, ...Array(8 - l.length - r.length).fill(0), ...r]; + if (groups.length !== 8) return null; + const bytes = []; + for (const g of groups) bytes.push((g >> 8) & 0xff, g & 0xff); + if (tailV4) bytes.splice(12, 4, ...tailV4); + return bytes; +}; + +const bytesToV4 = (b) => b.join("."); + +const classifyV6 = (addr) => { + const b = v6Bytes(addr); + if (!b) return "unparseable IPv6 address"; + const zero = (from, to) => b.slice(from, to).every((x) => x === 0); + if (zero(0, 16)) return "unspecified address"; + if (zero(0, 15) && b[15] === 1) return "loopback"; + // ::ffff:0:0/96 — IPv4-mapped. Check the embedded address under v4 rules. + if (zero(0, 10) && b[10] === 0xff && b[11] === 0xff) { + const inner = bytesToV4(b.slice(12)); + return classifyV4(inner) ? `IPv4-mapped ${inner} (${classifyV4(inner)})` : null; + } + // 64:ff9b::/96 — NAT64. Same treatment. + if (b[0] === 0x00 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b && zero(4, 12)) { + const inner = bytesToV4(b.slice(12)); + return classifyV4(inner) ? `NAT64 ${inner} (${classifyV4(inner)})` : null; + } + // 2002::/16 — 6to4. The embedded v4 is bytes 2-5. + if (b[0] === 0x20 && b[1] === 0x02) { + const inner = bytesToV4(b.slice(2, 6)); + return classifyV4(inner) ? `6to4 ${inner} (${classifyV4(inner)})` : null; + } + if (b[0] === 0x01 && b[1] === 0x00 && zero(2, 8)) return "discard-only"; + if (b[0] === 0x20 && b[1] === 0x01 && (b[2] & 0xfe) === 0x0d && b[3] === 0xb8) + return "documentation"; + if (b[0] === 0x20 && b[1] === 0x01 && b[2] < 0x02) return "IETF protocol assignments"; + if ((b[0] & 0xfe) === 0xfc) return "unique local"; + if (b[0] === 0xfe && (b[1] & 0xc0) === 0x80) return "link-local"; + if (b[0] === 0xff) return "multicast"; + return null; +}; + +const classify = (addr) => (isIP(addr) === 4 ? classifyV4(addr) : classifyV6(addr)); + +let addresses; +const literal = isIP(host); +if (literal) { + addresses = [host]; +} else { + try { + // getaddrinfo, the same resolution curl performs. + addresses = (await lookup(host, { all: true, verbatim: true })).map( + (r) => r.address, + ); + } catch (err) { + refuse(`DNS lookup failed for ${host}: ${err.code ?? err.message}`); + } +} +if (!addresses.length) refuse(`no addresses for ${host}`); + +// Every address must pass. One bad answer in a round-robin set is enough to +// refuse the whole host. +for (const addr of addresses) { + const bad = classify(addr); + if (bad) refuse(`${host} resolves to ${addr} (${bad})`); +} + +process.stdout.write(`ALLOW ${host} ${port} ${addresses.join(",")}\n`); +``` + +
+ +## The fetch wrapper + +Save as `safe-fetch.sh` next to the guard and `source` it. It needs curl 7.59 +or newer for the comma-separated `--resolve` form. + +
+safe-fetch.sh + +```bash +#!/usr/bin/env bash +# safe_curl [extra curl args...] +# +# Fetch a URL that an unauthenticated remote server chose for you. +# +# * validates the destination before anything is sent, +# * pins the connection to the addresses it validated, so a second DNS +# answer cannot move the request after the check, +# * follows no redirect, ever. +# +# The body goes to stdout. The status line and response headers go to stderr, +# so `safe_curl "$URL" | jq .` and `safe_curl "$URL" 2>&1 | grep -i www-auth` +# both work. Exit 2 means refused and nothing was sent. + +# The Node guard that classifies the destination. Defaults to the file next to +# this one; override with MCP_DISCOVERY_GUARD. +MCP_DISCOVERY_GUARD=${MCP_DISCOVERY_GUARD:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/mcp-discovery-guard.mjs} + +safe_curl() { + local url=$1; shift + local verdict host port addrs body headers status rc + + [ -f "$MCP_DISCOVERY_GUARD" ] || { + printf 'safe_curl: guard not found: %s\n' "$MCP_DISCOVERY_GUARD" >&2; return 2; } + + verdict=$(node "$MCP_DISCOVERY_GUARD" "$url") + if [ $? -ne 0 ]; then + printf 'safe_curl: %s\n' "${verdict#REFUSE }" >&2; return 2 + fi + read -r _ host port addrs <<<"$verdict" + + body=$(mktemp); headers=$(mktemp) + status=$(curl -sS \ + --proto '=https' --tlsv1.2 \ + --resolve "$host:$port:$addrs" \ + --max-redirs 0 --max-time 15 --max-filesize 1048576 \ + -D "$headers" -o "$body" -w '%{http_code}' \ + "$@" "$url") + rc=$? + if [ $rc -ne 0 ]; then + printf 'safe_curl: transport error (curl exit %s) for %s\n' "$rc" "$url" >&2 + rm -f "$body" "$headers"; return 3 + fi + + case $status in + 3??) + printf 'safe_curl: %s answered %s -> %s\n' "$url" "$status" \ + "$(awk 'tolower($1)=="location:"{print $2}' "$headers" | tr -d '\r')" >&2 + printf 'safe_curl: redirect not followed. Run safe_curl against that URL to validate it on its own merits.\n' >&2 + rm -f "$body" "$headers"; return 2 ;; + esac + + cat "$headers" >&2 + cat "$body" + rm -f "$body" "$headers" + return 0 +} +``` + +
+ +## Executed evidence + +Run 2026-09-24 on Linux aarch64, Node v24.20.0, curl 8.5.0. + +### Adversarial metadata, end to end + +Not a synthetic URL list: a hostile `WWW-Authenticate` challenge and two hostile +protected-resource documents, parsed exactly the way Step 2 tells you to parse +them, then fetched. + +``` +resource_metadata from challenge: https://localtest.me/.well-known/oauth-protected-resource +safe_curl: localtest.me resolves to ::1 (loopback) + -> exit 2 +issuer from protected-resource doc: http://169.254.169.254/latest/meta-data/ +safe_curl: scheme is not https: http: + -> exit 2 +issuer from protected-resource doc: https://[::ffff:169.254.169.254] +safe_curl: ::ffff:a9fe:a9fe resolves to ::ffff:a9fe:a9fe (IPv4-mapped 169.254.169.254 (link-local / cloud metadata)) + -> exit 2 +``` + +`localtest.me` is the case a string check cannot catch: an ordinary-looking +public hostname with a real public DNS record pointing at loopback. Nothing in +the URL text is suspicious. Only resolution reveals it. + +### Every refusal class + +``` +http://wisdom-api.enterpret.com/x REFUSE scheme is not https: http: +file:///etc/passwd REFUSE scheme is not https: file: +gopher://wisdom-api.enterpret.com/ REFUSE scheme is not https: gopher: +https://127.0.0.1/.well-known/oauth-protected-resource REFUSE 127.0.0.1 resolves to 127.0.0.1 (loopback) +https://localhost/x REFUSE localhost resolves to 127.0.0.1 (loopback) +https://[::1]/x REFUSE ::1 resolves to ::1 (loopback) +https://169.254.169.254/latest/meta-data/iam/security-credentials/ REFUSE 169.254.169.254 resolves to 169.254.169.254 (link-local / cloud metadata) +https://[fd00::1]/x REFUSE fd00::1 resolves to fd00::1 (unique local) +https://[fe80::1]/x REFUSE fe80::1 resolves to fe80::1 (link-local) +https://10.0.0.5/x REFUSE 10.0.0.5 resolves to 10.0.0.5 (private) +https://192.168.1.1/x REFUSE 192.168.1.1 resolves to 192.168.1.1 (private) +https://172.16.0.1/x REFUSE 172.16.0.1 resolves to 172.16.0.1 (private) +https://100.64.0.1/x REFUSE 100.64.0.1 resolves to 100.64.0.1 (carrier-grade NAT) +https://[::ffff:127.0.0.1]/x REFUSE ::ffff:7f00:1 resolves to ::ffff:7f00:1 (IPv4-mapped 127.0.0.1 (loopback)) +https://[64:ff9b::7f00:1]/x REFUSE 64:ff9b::7f00:1 resolves to 64:ff9b::7f00:1 (NAT64 127.0.0.1 (loopback)) +https://[2002:7f00:1::]/x REFUSE 2002:7f00:1:: resolves to 2002:7f00:1:: (6to4 127.0.0.1 (loopback)) +https://attacker:pw@wisdom-api.enterpret.com/x REFUSE URL carries userinfo +https://wisdom-api.enterpret.com:8443/x REFUSE port is not 443: 8443 +https://localtest.me/x REFUSE localtest.me resolves to ::1 (loopback) +not-a-url REFUSE not a URL: not-a-url +``` + +### Redirects are refused, not followed + +``` +$ safe_curl "https://google.com/" +safe_curl: https://google.com/ answered 301 -> https://www.google.com/ +safe_curl: redirect not followed. Run safe_curl against that URL to validate it on its own merits. +exit=2 +``` + +### The documented flow still works + +The same two fetches Step 2 asks for, against a real public provider: + +``` +$ safe_curl "https://wisdom-api.enterpret.com/server/mcp" -X POST \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}' +HTTP/2 401 +www-authenticate: Bearer realm="mcp", resource_metadata="https://wisdom-api.enterpret.com/server/mcp/.well-known/oauth-protected-resource", scope="mcp:read mcp:write" + +$ safe_curl "https://wisdom-api.enterpret.com/server/mcp/.well-known/oauth-protected-resource" +{"resource":"https://wisdom-api.enterpret.com/server/mcp","authorization_servers":["https://oauth.enterpret.com"],"scopes_supported":["email","mcp:read","mcp:write"]} +``` + +A guard that refused the normal path too would just get switched off. It does +not. diff --git a/packages/skills-catalog/catalog/optional/software-development/connect-agent-tools/SKILL.md b/packages/skills-catalog/catalog/optional/software-development/connect-agent-tools/SKILL.md index 68c631b563..22cf701377 100644 --- a/packages/skills-catalog/catalog/optional/software-development/connect-agent-tools/SKILL.md +++ b/packages/skills-catalog/catalog/optional/software-development/connect-agent-tools/SKILL.md @@ -38,6 +38,12 @@ behind it — commands, observed output, and the correlated agent run — is in [`references/verification-log.md`](references/verification-log.md), so you can redo it rather than take the label on trust. +**How this skill cites the product.** A file and a symbol you can grep for, +never a line number: line numbers drift between the read and the reader, and +this skill has already been caught citing four that had moved. Every source and +runbook citation below was re-read at App commit `18dac1e1` (24 September 2026). +If the commit in front of you disagrees, the commit wins — record the drift. + ## Use This When - An agent cannot reach a tool it needs, and nobody has established why. @@ -210,17 +216,17 @@ Self-hosted does not imply desktop-compatible. A locally launched MCP server that only calls a vendor's hosted API is portable; one that drives an open desktop app, local files or a private network needs those where the runtime is. -Two configuration facts decide most of what follows, both at App commit -`9335b7db10`: +Two configuration facts decide most of what follows, both re-read at +`18dac1e1`: -- `local_trusted` **forces** exposure to `private` - (`server/src/config.ts:187-190`). A shape-A instance can never reach the +- `local_trusted` **forces** exposure to `private` (`deploymentExposure` in + `server/src/config.ts`). A shape-A instance can never reach the `authenticated` + `public` combination that the private-endpoint guard refuses. - Paperclip refuses private, loopback and reserved MCP addresses only on a deployment that is both authenticated and publicly exposed - (`allowPrivateRemoteEndpoints` at `server/src/services/tool-access.ts:3195-3200`, - mirrored in `tool-gateway.ts:3135-3140`), and refuses link-local addresses in + (`allowPrivateRemoteEndpoints` at `server/src/services/tool-access.ts`, + mirrored in `tool-gateway.ts`), and refuses link-local addresses in every mode (`server/src/services/remote-http-endpoint-guard.ts`). Read those at the commit in front of you and record it. Never propose a tunnel, @@ -231,10 +237,11 @@ always can.** `GET /api/companies/:companyId/tools/runtime-health` returns the live `supportMatrix` for the instance and requires board access: a run-scoped agent credential gets `403 {"error":"Board access required"}`, while a board actor on an instance they own reads it fine (both executed 19 September 2026; -route at `server/src/routes/tool-access.ts:2500-2504`). So if you are the agent, -ask the operator to run it and paste the `supportMatrix` block, or read the -configuration directly. Do not guess it, and do not report the `403` as though -the surface were unavailable — it is available to the person you are asking. +route at the `tools/runtime-health` handler in +`server/src/routes/tool-access.ts`). So if you are the agent, ask the operator +to run it and paste the `supportMatrix` block, or read the configuration +directly. Do not guess it, and do not report the `403` as though the surface +were unavailable — it is available to the person you are asking. Label every capability you establish **verified**, **untested**, **unsupported** or **deferred**, each with its reason. Fill in a copy of @@ -254,7 +261,7 @@ governing principle: "A catalog entry is a convenience layer, not a prerequisite. An operator can connect any standards-compliant remote HTTP MCP server from **Connect your own MCP server** or **Paste a config** with no Paperclip code change at all — including servers that need browser sign-in." -(`CONNECTOR-PLAYBOOK.md:23-26`.) +(`CONNECTOR-PLAYBOOK.md`, opening section.) 1. **An existing connector.** Check the catalog and the provider pages first. If a connector exists and the workflow still fails, this is a configuration, @@ -268,13 +275,14 @@ Paperclip code change at all — including servers that need browser sign-in." Name which condition failed rather than improvising. **None of those four conditions needs Paperclip Cloud.** DCR is - instance-local: each instance registers its own public client against its own - `/api/tools/oauth/callback`, and "Cloud-hosted and self-hosted instances use - the SAME path — the only per-instance difference is the hostname inside the - redirect URI" (`CONNECTOR-PLAYBOOK.md:1569-1578`). CIMD is the one tier that - needs a public HTTPS `PAPERCLIP_PUBLIC_URL`, and a deployment without one - falls through to DCR rather than failing. A self-hoster whose provider cannot - do DCR registers their own client and sets + instance-local: each instance registers its own public client against its + own `/api/tools/oauth/callback`, and "Cloud-hosted and self-hosted + instances use the SAME path — the only per-instance difference is the + hostname inside the redirect URI" (`CONNECTOR-PLAYBOOK.md`, **Dynamic + client registration (RFC 7591)** — grep `use the SAME path`). CIMD is the + one tier that needs a public HTTPS `PAPERCLIP_PUBLIC_URL`, and a deployment + without one falls through to DCR rather than failing. A self-hoster whose + provider cannot do DCR registers their own client and sets `PAPERCLIP_TOOL_OAUTH__CLIENT_ID` / `_SECRET`. The only genuinely Cloud-gated path is the curated `platform_shared` Paperclip-managed OAuth profile; if that is the only option, say so and stop rather than implying a @@ -316,22 +324,23 @@ anything: - Start writes at **Off** on a server nobody has reviewed and promote deliberately. Re-run **Refresh actions** after the server changes. - **Then re-check the profile, because promotion is not durable.** At App commit - `9335b7db10`, a connection created from a pasted URL is flagged - `unverifiedServer: true` and `quarantineNewEntries: false` in the same - expression (`server/src/services/tool-access.ts:12721`), so every catalog - refresh auto-allows whatever new tools the provider has started advertising — - including ones classified `destructive`. Verified by execution; see **F1** in + **Then re-check the profile, because promotion is not durable.** A + connection created from a pasted URL is flagged `unverifiedServer: true` and + `quarantineNewEntries: false` in the same expression — grep + `unverifiedServer` in `server/src/services/tool-access.ts` — so every catalog + refresh auto-allows whatever new tools the provider has started advertising, + including ones classified `destructive`. Verified by execution at + `9335b7db10` and still present at `18dac1e1`; see **F1** in [`references/deployment-support-matrix.md`](references/deployment-support-matrix.md). On a server you do not control, the tool set is provider-controlled, not operator-controlled. Say that out loud in your report. -- **Check that the connection is installed on the agent, not merely permitted.** - Finishing the wizard with `access: {agentIds: [...]}` writes the tool-profile - binding but no install row, and readiness needs both - (`server/src/services/connection-intents.ts:241-248`). The symptom is a green - **Connected** badge and an allowed tool the agent cannot see. Verified on two - instances — **F10** in the same reference. +- **Check that the connection is installed on the agent, not merely + permitted.** Finishing the wizard with `access: {agentIds: [...]}` writes + the tool-profile binding but no install row, and readiness needs both (the + `usable` predicate in `server/src/services/connection-intents.ts`). The + symptom is a green **Connected** badge and an allowed tool the agent cannot + see. Verified on two instances — **F10** in the same reference. ```sh curl -s -H "Authorization: Bearer $TOKEN" \ @@ -402,7 +411,8 @@ so contributors no longer need a private validation issue for it. Its nine scenarios are setup and consent, authentication, catalog and configuration, allowed execution, denied execution, runtime delivery, refresh and recovery, revoke and reconnect, and activity and secret handling -(`CONNECTOR-PLAYBOOK.md:1445-1455`). +(`CONNECTOR-PLAYBOOK.md`, **Step 9: Align With Production Validation** — the +scenario table). Record **pass**, **fail**, **not run** or **not applicable** per scenario with a reason for the last two, plus environment, method key, commit, reproduction diff --git a/packages/skills-catalog/catalog/optional/software-development/connect-agent-tools/references/deployment-support-matrix.md b/packages/skills-catalog/catalog/optional/software-development/connect-agent-tools/references/deployment-support-matrix.md index 4df4d0a9e2..7a4d9513f4 100644 --- a/packages/skills-catalog/catalog/optional/software-development/connect-agent-tools/references/deployment-support-matrix.md +++ b/packages/skills-catalog/catalog/optional/software-development/connect-agent-tools/references/deployment-support-matrix.md @@ -38,7 +38,7 @@ instance's configuration, not from a product tier name. | Deployment mode | `PAPERCLIP_DEPLOYMENT_MODE` | `local_trusted`, `authenticated` | `local_trusted` | | Exposure | `PAPERCLIP_DEPLOYMENT_EXPOSURE` | `private`, `public` | `private` | -Source: `packages/shared/src/constants.ts:4-8` and `server/src/config.ts:170-190` +Source: `packages/shared/src/constants.ts` and `server/src/config.ts` at Paperclip App commit `9335b7db10`. Both are also settable as `config.server.deploymentMode` / `config.server.exposure` in the instance's `config.json`, which is what `config.ts` reads after the environment. @@ -46,7 +46,8 @@ at Paperclip App commit `9335b7db10`. Both are also settable as One consequence is easy to miss and worth writing down, because it is what makes the same-machine self-hosted path so capable: -> `local_trusted` **forces** exposure to `private`. `server/src/config.ts:187-190` +> `local_trusted` **forces** exposure to `private`. `deploymentExposure` in + `server/src/config.ts` > ignores `PAPERCLIP_DEPLOYMENT_EXPOSURE` when the mode is `local_trusted`. So a same-machine self-hosted instance can never land in the @@ -75,7 +76,7 @@ Both were hit while filling this matrix, on `authenticated` + `public`: A public self-hosted instance needs an external PostgreSQL. 2. **An explicit, resolvable public base URL.** `auth.baseUrlMode` must be explicit and `auth.publicBaseUrl` is required - (`packages/shared/src/config-schema.ts:167-187`). This value is not only + (`packages/shared/src/config-schema.ts`). This value is not only browser-facing: the runtime hands it to spawned agents as their control-plane URL, so it must resolve **from the machine the runtime runs on**, not just from your browser. A placeholder hostname produces `getaddrinfo ENOTFOUND` on @@ -114,7 +115,7 @@ this package: [`verification-log.md`](./verification-log.md). | Capability | A. Self-hosted, same machine | B. Self-hosted, server/VPS | C. Paperclip Cloud | Evidence | | --- | --- | --- | --- | --- | | `mcp_remote` to a public provider endpoint | `untested` | **`verified`** on `authenticated` + `public` | `untested` | B: DeepWiki connected, catalog refreshed to 3 tools, `read_wiki_structure` executed through the gateway, `decision: allowed`. | -| `mcp_remote` to a loopback endpoint (`127.0.0.1`) | **`verified`** | **`verified`** on `private`; **`unsupported`** on `public` | `unsupported` | A: connected, `healthStatus ok`, 7 tools, real agent runs against it. B `public`: `HTTP 400 {"code":"remote_http_private_endpoint"}` on connect, `HTTP 502` on refresh. Guard: `allowPrivateRemoteEndpoints`, `server/src/services/tool-access.ts:3195-3200`. | +| `mcp_remote` to a loopback endpoint (`127.0.0.1`) | **`verified`** | **`verified`** on `private`; **`unsupported`** on `public` | `unsupported` | A: connected, `healthStatus ok`, 7 tools, real agent runs against it. B `public`: `HTTP 400 {"code":"remote_http_private_endpoint"}` on connect, `HTTP 502` on refresh. Guard: `allowPrivateRemoteEndpoints`, `server/src/services/tool-access.ts`. | | `mcp_remote` to a link-local address | `untested` | `untested` | `untested` | `isAlwaysDeniedLinkLocalIp` in `server/src/services/remote-http-endpoint-guard.ts` denies in every mode. Source-cited only; no live call was made. | | `local_stdio` with an approved template | `untested` — instance declares it **supported** | `untested` — instance declares it **unsupported** in both exposures | `untested` | The live `supportMatrix` differs by shape (blocks quoted in the verification report). No stdio connection was created, so the capability itself is untested everywhere. | | `rest_api` through the connected MCP gateway | `untested` | `untested` | `untested` | Not exposed through the gateway; needs an execution adapter. Runbook "Transport support and boundaries". | @@ -131,12 +132,12 @@ Pick the shape that matches the provider. | --- | --- | --- | --- | --- | | `auth: "none"` remote MCP | **`verified`** | **`verified`** (both exposures) | `untested` | Connect → catalog → gateway execution on both instances. | | API key / PAT in a header | **`verified`** | `untested` | `untested` | A: connection with `credentialRefs: [{placement: "header", key: "x-api-key", secretId}]`; the provider's request log shows the header arrived, and the value appears nowhere in Paperclip (see redaction row). | -| OAuth via dynamic client registration (RFC 7591) | `untested` | `untested` | `untested` | **DCR is instance-local.** Each instance registers its own public client against its own `/api/tools/oauth/callback`. "Cloud-hosted and self-hosted instances use the SAME path — the only per-instance difference is the hostname inside the redirect URI." `CONNECTOR-PLAYBOOK.md:1569-1578`. Consent needs an authorized provider account; not exercised. | +| OAuth via dynamic client registration (RFC 7591) | `untested` | `untested` | `untested` | **DCR is instance-local.** Each instance registers its own public client against its own `/api/tools/oauth/callback`. "Cloud-hosted and self-hosted instances use the SAME path — the only per-instance difference is the hostname inside the redirect URI." `CONNECTOR-PLAYBOOK.md`, **Dynamic client registration (RFC 7591)**. Consent needs an authorized provider account; not exercised. | | OAuth via CIMD | `untested`; falls through to DCR on loopback/plain HTTP | `untested` with a public HTTPS origin; falls through without one | `untested` | CIMD "requires a public HTTPS base URL … the authorization server has to fetch that document server-to-server, so loopback and plain-HTTP deployments fall through to the next tier." `GENERIC-REMOTE-MCP.md:94-104`. Neither test instance had a public HTTPS origin. | -| OAuth with a client you registered yourself | `untested` | `untested` | `untested` | Deployment-preconfigured `PAPERCLIP_TOOL_OAUTH__CLIENT_ID` / `_SECRET` outranks every other tier — `server/src/services/tool-access.ts:8787-8804`. Not exercised. | -| OAuth redirect from a plain-HTTP non-loopback origin | n/a — loopback is HTTP-allowed | **`unsupported`** for `https-or-loopback-http` providers | n/a — Cloud is HTTPS | Fails fast with `oauth_redirect_origin_unsupported`; configure TLS. `CONNECTOR-PLAYBOOK.md:1580-1594`. | +| OAuth with a client you registered yourself | `untested` | `untested` | `untested` | Deployment-preconfigured `PAPERCLIP_TOOL_OAUTH__CLIENT_ID` / `_SECRET` outranks every other tier — `safeOAuthEndpointUrl` in `server/src/services/tool-access.ts`. Not exercised. | +| OAuth redirect from a plain-HTTP non-loopback origin | n/a — loopback is HTTP-allowed | **`unsupported`** for `https-or-loopback-http` providers | n/a — Cloud is HTTPS | Fails fast with `oauth_redirect_origin_unsupported`; configure TLS. `CONNECTOR-PLAYBOOK.md`, **Redirect-URI constraints**. | | Provider-generated secret-bearing URL | `untested` | `untested` | `untested` | Generic runtime path is complete. Treat the URL as a credential. | -| Paperclip-managed OAuth (`platform_shared`) | **`unsupported`** | **`unsupported`** | `untested` | Requires a reviewed Cloud connector profile and Cloud's fixed provider callback. `CONNECTOR-PLAYBOOK.md:184`. | +| Paperclip-managed OAuth (`platform_shared`) | **`unsupported`** | **`unsupported`** | `untested` | Requires a reviewed Cloud connector profile and Cloud's fixed provider callback. `CONNECTOR-PLAYBOOK.md`, **Authentication support matrix**. | **No Cloud account is required for self-hosted OAuth.** The playbook is explicit that DCR needs neither Paperclip ID nor Paperclip Connect, and that @@ -149,7 +150,7 @@ one row of this table, not the default path. Note that the whole OAuth block is | Capability | A. Same machine | B. Server/VPS | C. Cloud | Evidence | | --- | --- | --- | --- | --- | -| Connect by URL, no code change | **`verified`** | **`verified`** | `untested` | `POST …/tools/apps/connect` with a pasted link, then `/finish`. No catalog entry authored; `CONNECTOR-PLAYBOOK.md:23-26` says none is needed, and none was. | +| Connect by URL, no code change | **`verified`** | **`verified`** | `untested` | `POST …/tools/apps/connect` with a pasted link, then `/finish`. No catalog entry authored; `CONNECTOR-PLAYBOOK.md` (opening section, grep `Connect your own MCP server`) says none is needed, and none was. | | Catalog discovery and risk classification | **`verified`** | **`verified`** | `untested` | A: `read` / `write` / `destructive` assigned correctly across 7 tools with no manual input. | | Effective policy on the acting agent | **`verified`** | **`verified`** | `untested` | A: `allowedToolNames` moved `[] → [list_notes, read_note] → +create_note` as entries were added; an ungranted agent stayed `[]`. | | Narrow read through the gateway | **`verified`** | **`verified`** | `untested` | A: two agent runs. B: DeepWiki `read_wiki_structure`. | @@ -207,13 +208,13 @@ findings that do not change the advice. ### F1 — a pasted MCP server can widen its own permissions after approval -On every catalog refresh, newly discovered entries are added to the connection's -active managed profile unless `quarantineNewEntries` is set — and for a -**user-pasted URL** it is explicitly set to `false` -(`server/src/services/tool-access.ts:12721`, in the same expression that flags -the connection `unverifiedServer: true`). Curated gallery apps and -Paperclip-managed cloud connectors *do* quarantine. The least-trusted class does -not. +On every catalog refresh, newly discovered entries are added to the +connection's active managed profile unless `quarantineNewEntries` is set — and +for a **user-pasted URL** it is explicitly set to `false` +(`server/src/services/tool-access.ts`, grep `unverifiedServer: true`, in the +same expression that flags the connection `unverifiedServer: true`). Curated +gallery apps and Paperclip-managed cloud connectors *do* quarantine. The +least-trusted class does not. Executed: a connection finished with exactly two tools enabled and access limited to one agent. The provider then advertised `export_notes`, which returns every @@ -249,11 +250,12 @@ for permission questions, and a real agent run for execution questions. `POST …/tools/apps/{id}/finish` with `access: {agentIds: [...]}` creates the tool-profile binding but **no `tool_connection_install` row**. Agent readiness -requires both (`server/src/services/connection-intents.ts:241-248`), so the tool -never enters the agent's session. Both wizard-created connections, on both -instances, showed `installs: []` alongside a correct `allowedToolNames`. The -board's access view showed the tool allowed and the connection green; the agent -got `needs_user_action` and could not run. +requires both (the `usable` predicate in +`server/src/services/connection-intents.ts`), so the tool never enters the +agent's session. Both wizard-created connections, on both instances, showed +`installs: []` alongside a correct `allowedToolNames`. The board's access view +showed the tool allowed and the connection green; the agent got +`needs_user_action` and could not run. **What this means for the skill's advice.** After finishing the wizard, check `GET /api/tool-connections/{id}/installs` — not just the effective profile. diff --git a/packages/skills-catalog/catalog/optional/software-development/connect-agent-tools/references/verification-log.md b/packages/skills-catalog/catalog/optional/software-development/connect-agent-tools/references/verification-log.md index ec687c5659..98ebd30c07 100644 --- a/packages/skills-catalog/catalog/optional/software-development/connect-agent-tools/references/verification-log.md +++ b/packages/skills-catalog/catalog/optional/software-development/connect-agent-tools/references/verification-log.md @@ -27,12 +27,12 @@ Read from each instance's `config.json` and confirmed against its live `/api/health`. Neither instance had `PAPERCLIP_DEPLOYMENT_MODE`, `PAPERCLIP_DEPLOYMENT_EXPOSURE`, or `PAPERCLIP_PUBLIC_URL` set in the environment; the equivalent `config.json` keys are the source, which is what -`server/src/config.ts:170-190` reads after the environment. +`server/src/config.ts` reads after the environment. | | **Instance A — shape A** | **Instance B — shape B** | | --- | --- | --- | | Deployment mode | `config.server.deploymentMode = local_trusted` | `config.server.deploymentMode = authenticated` | -| Exposure | **forced** `private` by `config.ts:187-190` | run as `private` **and** `public` | +| Exposure | **forced** `private` by `deploymentExposure` in `config.ts` | run as `private` **and** `public` | | Public base URL | not set, not needed | `auth.publicBaseUrl` set and mandatory — see F7 | | Bind | loopback `127.0.0.1:3810` | loopback `127.0.0.1:3820` | | Database | embedded PostgreSQL | **external** PostgreSQL — embedded is refused, see F7 | @@ -97,8 +97,9 @@ public exposure; the private-endpoint refusal happens at connect time, not here. account on that provider. The unauthenticated metadata probe in the skill's quickstart is current; the consent half stays `untested`. -No catalog definition was authored. `CONNECTOR-PLAYBOOK.md:23-26` says the -pasted-URL path needs no code change, and it did not. +No catalog definition was authored. `CONNECTOR-PLAYBOOK.md` (opening section, +grep `Connect your own MCP server`) says the pasted-URL path needs no code +change, and it did not. ### Provider evidence record @@ -114,10 +115,11 @@ pasted-URL path needs no code change, and it did not. ## 3. The nine production-validation scenarios -These are the runbook's scenarios (`doc/connections/CONNECTOR-PLAYBOOK.md:1445-1455`). -Environment for every row unless stated otherwise: **instance A**, -`local_trusted` + `private`, commit `9335b7db10`, method key -`connect-your-own-mcp-server` (pasted URL, `authMode: none`). +These are the runbook's scenarios (`doc/connections/CONNECTOR-PLAYBOOK.md`, +**Step 9: Align With Production Validation**). Environment for every row +unless stated otherwise: **instance A**, `local_trusted` + `private`, commit +`9335b7db10`, method key `connect-your-own-mcp-server` (pasted URL, `authMode: +none`). ### 1. Setup and consent — pass @@ -229,15 +231,15 @@ Environment for every row unless stated otherwise: **instance A**, invocation for `create-note`. That correlation is the whole proof: the agent, not the tester, did the work, and it went through the managed connection. -- **Instance B, `authenticated` + `public`.** Run `f027aa16…`, `succeeded`, one - correlated invocation: - `mcp.app-gallery-link-…:read-wiki-structure | allow | succeeded | args {"repoName": "paperclipai/paperclip"}`. - The agent reported DeepWiki's 13 top-level sections verbatim, and separately - attempted `read_wiki_contents` — the tool it was *not* granted at `finish` — - which failed at local dispatch with no request reaching DeepWiki. So shape B - has an allowed agent execution and a hidden-tool denial in the same run, - against a real third-party provider. No provider-side readback there: DeepWiki - is read-only. +- **Instance B, `authenticated` + `public`.** Run `f027aa16…`, `succeeded`, + one correlated invocation: `mcp.app-gallery-link-…:read-wiki-structure | + allow | succeeded | args {"repoName": "paperclipai/paperclip"}`. The agent + reported DeepWiki's 13 top-level sections verbatim, and separately attempted + `read_wiki_contents` — the tool it was *not* granted at `finish` — which + failed at local dispatch with no request reaching DeepWiki. So shape B has + an allowed agent execution and a hidden-tool denial in the same run, against + a real third-party provider. No provider-side readback there: DeepWiki is + read-only. - **It took three shape-B runs to get there, and both failures were findings rather than flakes.** Run 1 failed on `ENOTFOUND` because the mandatory `auth.publicBaseUrl` is also what the runtime hands spawned agents as their @@ -264,11 +266,12 @@ Environment for every row unless stated otherwise: **instance A**, `POST …/grants/installations {"isDefault": true}` → a live gateway call. - **Expected.** Revocation blocks later execution; reconnect reuses the intended identity. -- **Actual.** After revocation the gateway refuses: - `{"error": {"message": "Organization authorization is required", "reasonCode": "organization_authorization_required"}}`, - logged as `call_failed`. Disabling the connection refuses earlier, at policy: - `deny / deny_disabled_connection`. After re-adding the installation the same - read succeeds again, on the same connection id, with a new grant id and no +- **Actual.** After revocation the gateway refuses: `{"error": {"message": + "Organization authorization is required", "reasonCode": + "organization_authorization_required"}}`, logged as `call_failed`. Disabling + the connection refuses earlier, at policy: `deny / + deny_disabled_connection`. After re-adding the installation the same read + succeeds again, on the same connection id, with a new grant id and no duplicate connection. The gap is **F4**. ### 9. Activity and secret handling — pass @@ -306,13 +309,13 @@ them at the step where they bite. The rest are recorded here only. ### F1 — a pasted MCP server can widen its own permissions after approval -On every catalog refresh, newly discovered entries join the connection's active -managed profile unless `quarantineNewEntries` is set — and for a **user-pasted -URL** it is explicitly set to `false`, in the same expression that flags the -connection `unverifiedServer: true` -(`server/src/services/tool-access.ts:12721`). Curated gallery apps and -Paperclip-managed cloud connectors *do* quarantine. The least-trusted class does -not. +On every catalog refresh, newly discovered entries join the connection's +active managed profile unless `quarantineNewEntries` is set — and for a +**user-pasted URL** it is explicitly set to `false`, in the same expression +that flags the connection `unverifiedServer: true` +(`server/src/services/tool-access.ts`, grep `unverifiedServer: true`). Curated +gallery apps and Paperclip-managed cloud connectors *do* quarantine. The +least-trusted class does not. **Executed, on the recommended wizard path.** A connection was finished with exactly two tools enabled (`list_notes` allowed, `create_note` ask-first) and @@ -364,10 +367,11 @@ as board" and show the agent's own decision beside the result. ### F10 — the connect wizard grants the policy but not the install `POST …/tools/apps/{id}/finish` with `access: {agentIds: [...]}` creates the -tool-profile binding — the agent's `allowedToolNames` correctly lists the chosen -tool — but creates **no `tool_connection_install` row**. Agent readiness -(`usableConnectionForAgent`, `server/src/services/connection-intents.ts:241-248`) -requires the connection to be both installed for the agent and permitted: +tool-profile binding — the agent's `allowedToolNames` correctly lists the +chosen tool — but creates **no `tool_connection_install` row**. Agent +readiness (`usableConnectionForAgent`, the `usable` predicate in +`server/src/services/connection-intents.ts`) requires the connection to be +both installed for the agent and permitted: ```ts const usable = (connection) => connection @@ -447,7 +451,7 @@ Both were found by hitting them: public self-hosted instance cannot use the embedded database at all. 2. `auth.baseUrlMode must be explicit when deploymentMode=authenticated and exposure=public`, plus `auth.publicBaseUrl` required - (`packages/shared/src/config-schema.ts:167-187`). + (`packages/shared/src/config-schema.ts`). Neither is a defect. Both are the first two walls a self-hoster hits, which is why they are in the shape-B row of the matrix. diff --git a/packages/skills-catalog/generated/catalog.json b/packages/skills-catalog/generated/catalog.json index b27e04f5e6..57813b44f6 100644 --- a/packages/skills-catalog/generated/catalog.json +++ b/packages/skills-catalog/generated/catalog.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "packageName": "@paperclipai/skills-catalog", "packageVersion": "0.3.1", - "generatedAt": "2026-09-20T19:59:29.226Z", + "generatedAt": "2026-09-24T21:08:13.357Z", "skills": [ { "id": "paperclipai:bundled:docs:doc-maintenance", @@ -1147,14 +1147,14 @@ { "path": "SKILL.md", "kind": "skill", - "sizeBytes": 20964, - "sha256": "ecdf045a004da99c6ed9a3384b6000151d6965564d2ed1543c76c94b336811b7" + "sizeBytes": 23654, + "sha256": "2b8ed818bc969425f6ebb7de0b70893b69ba6cbeae55d4a75660b4ae82046302" }, { "path": "references/catalog-contract.md", "kind": "reference", - "sizeBytes": 13589, - "sha256": "f8a659b8c5790fd7c7b6d0a7bac1d3e16e175072f18c97ce3e5db1924f9abda3" + "sizeBytes": 15776, + "sha256": "e4a7f973ac2278bc23f86bf74e0199574d3341e91fe092a0fa06494a736ce930" }, { "path": "references/live-acceptance.md", @@ -1165,11 +1165,17 @@ { "path": "references/offline-verification.md", "kind": "reference", - "sizeBytes": 18496, - "sha256": "3b5a73bc50963bc5a1d7044817642022397eb42229f78a75df4d3b3c8a43b0f1" + "sizeBytes": 18480, + "sha256": "f887188302dab5c3ea947a29fb21ebfe4281e57cf83c02bcfbfb42c4f2711500" + }, + { + "path": "references/safe-discovery.md", + "kind": "reference", + "sizeBytes": 16680, + "sha256": "d65e3dbfee183e46923af1ac3b088dc589277551749d45cd8e4867db50c0d687" } ], - "contentHash": "sha256:830e6a1d3d1dfc06372e4bd17883b6f916d5425d7c45b6e53bec8a1c8aa3c2aa" + "contentHash": "sha256:853e1f442eb14f95e72d312f305c657cf235c4cd501455ca0846fb58a043a889" }, { "id": "paperclipai:optional:software-development:connect-agent-tools", @@ -1202,8 +1208,8 @@ { "path": "SKILL.md", "kind": "skill", - "sizeBytes": 26975, - "sha256": "13ea45dccab92a5f7dfdd67baa5ffe2952987cbad7fa085cff8de5d0c5fc53ed" + "sizeBytes": 27587, + "sha256": "72634d7d8c3bbcbec22e29a011d917bf828c94236f5f9f5e17c68f83fd74199a" }, { "path": "references/chat-and-email.md", @@ -1214,17 +1220,17 @@ { "path": "references/deployment-support-matrix.md", "kind": "reference", - "sizeBytes": 21348, - "sha256": "7afb36d0e2baa4e3920d83332f539b332267ae63e41194dfcbfd145f48addb67" + "sizeBytes": 21528, + "sha256": "5ee2477d76c39842af4a0e5172a1acaae39d93406c84131ba1757851a458a35a" }, { "path": "references/verification-log.md", "kind": "reference", - "sizeBytes": 25233, - "sha256": "56cd4452bd75ad646113c1f62926aa6f2b2f965db2d1e9adea350afe60c55b14" + "sizeBytes": 25362, + "sha256": "fb3799a05d6f0054eeadc55cb7441278013f13959349e0479609fd6439ea3913" } ], - "contentHash": "sha256:15e41bb565bafee8b284fa21e32948a191e828f5956ee641ffe641d11804bd58" + "contentHash": "sha256:60fe80ab5181aeb9a55cbb5ccae090ac2bf38872435b08d7f98725eb0be9fd54" }, { "id": "paperclipai:optional:software-development:prepare-mcp-integration",