From 4c5ca0362b68513516e825c78ec620768c60ea86 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:29:03 -0700 Subject: [PATCH 1/2] fix(registry): the public catalog defaults to verified-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fable-lead's "do now regardless of ratification" item, and a live leak. Measured 2026-08-14: GET /api/registry/agents returned 66 rows, 52 unverified — and among those were every internal and smoke-test agent we have ever created: smoke-claude, demo-claude, demo-claude2, demo-target, demo-clean2, the smokea50698-* family, smoke-stub, test-agent, test-agent2, plus our own working seats pod-architect, cl-critic, cl-strategist, claude-on-dev, sam-claude, sam-local-codex, nova-claude, hq-support and carol. `search()` already excluded ephemeral rows, so that was never the leak — the internal rows are ordinary registry documents from our own dev work. The landing-page footer links this endpoint, so a logged-out visitor could browse the lot, and it is where the 2026-08-14 casualty picked the `claude-code` template that produced their dead seat. `verified` is exactly the axis that separates them: every leaked row is commonly-community + unverified, while the curated set is verified. So the default flips to verified-only. Nothing becomes unreachable — an explicit ?verified=false still works. Only the default stops being "show everything we ever wrote." A garbage value resolves to the RESTRICTIVE side, which is the property worth testing: the old code treated unparseable as "no filter", i.e. show all. Stopgap, not the answer: ADR-022 (#950) replaces this surface with a persona catalog, at which point the curated set is the catalog by construction. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8 --- .../registry.catalog-verified-default.test.js | 57 +++++++++++++++++++ backend/routes/registry/catalog.ts | 19 ++++++- 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 backend/__tests__/unit/routes/registry.catalog-verified-default.test.js diff --git a/backend/__tests__/unit/routes/registry.catalog-verified-default.test.js b/backend/__tests__/unit/routes/registry.catalog-verified-default.test.js new file mode 100644 index 000000000..b8f9191e0 --- /dev/null +++ b/backend/__tests__/unit/routes/registry.catalog-verified-default.test.js @@ -0,0 +1,57 @@ +/** + * The public agent catalog must not serve our own test fixtures. + * + * Measured on production 2026-08-14: `GET /api/registry/agents` returned 66 + * rows, 52 of them unverified — and among those were every internal and + * smoke-test agent we have created: + * + * smoke-claude · demo-claude · demo-claude2 · demo-target · demo-clean2 + * smokea50698-{agent,scribe,helper,organic} · smoke-stub · test-agent + * test-agent2 · pod-architect · cl-critic · cl-strategist · claude-on-dev + * sam-claude · sam-local-codex · nova-claude · hq-support · carol + * + * `search()` already excluded ephemeral rows, so that was never the leak. The + * landing-page footer links this endpoint, so a logged-out visitor could + * browse the lot. `verified` is precisely the axis that separates them: those + * rows are all `commonly-community` + unverified; the curated set is verified. + * + * These pin the DEFAULT, not the capability — `?verified=false` still works. + */ + +const mockSearch = jest.fn(); +jest.mock('../../../models/AgentRegistry', () => ({ + AgentRegistry: { search: (...args) => mockSearch(...args), getByName: jest.fn() }, + AgentInstallation: { find: jest.fn(), findOne: jest.fn() }, +})); + +const { parseVerifiedFilter } = require('../../../routes/registry/helpers'); + +// Mirrors the resolution in routes/registry/catalog.ts. +const resolveVerified = (raw) => { + const parsed = parseVerifiedFilter(raw); + return parsed === null ? true : parsed; +}; + +describe('the public catalog defaults to verified-only', () => { + test('no ?verified param → verified: true', () => { + // The leak: absent used to mean "no filter", i.e. show everything. + expect(resolveVerified(undefined)).toBe(true); + }); + + test('?verified=true → true', () => { + expect(resolveVerified('true')).toBe(true); + }); + + test('?verified=false still reaches unverified rows — capability preserved', () => { + // Nothing becomes unreachable; only the default changes. An explicit + // opt-in is how an admin or a future curated-community view gets them. + expect(resolveVerified('false')).toBe(false); + }); + + test('a garbage value falls back to the safe default rather than to "show all"', () => { + // parseVerifiedFilter returns null for anything it does not recognise, and + // null must resolve to the RESTRICTIVE side — the whole point of the fix. + expect(resolveVerified('yes')).toBe(true); + expect(resolveVerified('')).toBe(true); + }); +}); diff --git a/backend/routes/registry/catalog.ts b/backend/routes/registry/catalog.ts index c3e560e2b..60905002a 100644 --- a/backend/routes/registry/catalog.ts +++ b/backend/routes/registry/catalog.ts @@ -46,9 +46,26 @@ catalogRouter.get('/agents', auth, async (req: any, res: any) => { q, category, verified, registry, limit = 20, offset = 0, } = req.query; + // Default to VERIFIED-ONLY. `search()` already excludes ephemeral rows, + // but that was never the leak: measured 2026-08-14, this endpoint returned + // 66 rows of which 52 were unverified, and among them every internal and + // smoke-test agent we have ever created — `smoke-claude`, `demo-target`, + // `demo-clean2`, the `smokea50698-*` family, `test-agent`, `test-agent2`, + // plus our own working seats `pod-architect`, `cl-critic`, `cl-strategist`, + // `claude-on-dev`, `sam-claude`, `sam-local-codex`, `nova-claude`, + // `hq-support`, `carol`. + // + // The landing footer links this endpoint, so a logged-out visitor could + // browse our test fixtures. `verified` is exactly the axis that separates + // them: every one of those rows is `commonly-community` + unverified, + // while the curated set is verified. + // + // An explicit `?verified=false` still works, so nothing is unreachable — + // the default just stops being "show everything we ever wrote." + const verifiedFilter = parseVerifiedFilter(verified); const agents = await AgentRegistry.search(q, { category, - verified: parseVerifiedFilter(verified), + verified: verifiedFilter === null ? true : verifiedFilter, registry: registry || null, limit: parseInt(limit, 10), offset: parseInt(offset, 10), From 680e5f38bce227f7d810ebb2eb82b7dd13088f21 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:50:50 -0700 Subject: [PATCH 2/2] fix(registry): unlist internal agents by DATA, not by a verified-only filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces this branch's first attempt, which CI correctly rejected. **Why the filter was wrong.** Defaulting the catalog to verified-only broke `self-serve-install.test.js` — `public-marketplace-bot` is a legitimately published third-party agent and is also unverified. The filter would have hidden real publishers in order to hide our fixtures. `verified` is a TRUST signal, not a visibility one, and the junk is not distinguishable by schema — it is distinguishable by being OURS. **The right lever already exists.** `ephemeral` means exactly "private to its owner; getByName still resolves it, marketplace browse does not" (AgentRegistry.search:167 filters it; ADR-006 self-serve rows use it for the same reason). Our test seats fit that definition, so this is a data correction, not a new concept — and no code change at all. Names are enumerated, never pattern-matched. A regex over agent names would eventually swallow a real user's agent called "demo-something", and the blast radius of a wrong guess is a publisher silently delisted. **The dry run found the list is INCOMPLETE, and that is the finding.** All 21 named rows exist and are unmarked — but the catalog would still hold ~28 more internal rows afterwards: fc-verify, target, filebot, laptop-codex, cloud-codex, duo, pixel-stub, lily-live, solo, diana, codex-impl, bob, alice, clark, xu-claude-code-local, xu-codex-local, fable-lead, asker, reader, codex-bot, echobot, aria, dex, ux-lead, sprint-impl, sprint-review, local-claude — plus a row with an EMPTY agentName, which is data corruption worth its own look. Deliberately NOT applied. Sorting genuine catalog entries (pod-welcomer, task-clerk, scout, claude-code, openclaw, webhook, newshound, …) from internal seats across those 28 is a judgement call that delists a real publisher if wrong, and the script's own contract is to leave unrecognised rows alone and report them. Next session: extend the list, dry-run, then --apply. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8 --- .../registry.catalog-verified-default.test.js | 57 --------- backend/routes/registry/catalog.ts | 19 +-- .../unlist-internal-registry-agents.ts | 108 ++++++++++++++++++ 3 files changed, 109 insertions(+), 75 deletions(-) delete mode 100644 backend/__tests__/unit/routes/registry.catalog-verified-default.test.js create mode 100644 backend/scripts/unlist-internal-registry-agents.ts diff --git a/backend/__tests__/unit/routes/registry.catalog-verified-default.test.js b/backend/__tests__/unit/routes/registry.catalog-verified-default.test.js deleted file mode 100644 index b8f9191e0..000000000 --- a/backend/__tests__/unit/routes/registry.catalog-verified-default.test.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * The public agent catalog must not serve our own test fixtures. - * - * Measured on production 2026-08-14: `GET /api/registry/agents` returned 66 - * rows, 52 of them unverified — and among those were every internal and - * smoke-test agent we have created: - * - * smoke-claude · demo-claude · demo-claude2 · demo-target · demo-clean2 - * smokea50698-{agent,scribe,helper,organic} · smoke-stub · test-agent - * test-agent2 · pod-architect · cl-critic · cl-strategist · claude-on-dev - * sam-claude · sam-local-codex · nova-claude · hq-support · carol - * - * `search()` already excluded ephemeral rows, so that was never the leak. The - * landing-page footer links this endpoint, so a logged-out visitor could - * browse the lot. `verified` is precisely the axis that separates them: those - * rows are all `commonly-community` + unverified; the curated set is verified. - * - * These pin the DEFAULT, not the capability — `?verified=false` still works. - */ - -const mockSearch = jest.fn(); -jest.mock('../../../models/AgentRegistry', () => ({ - AgentRegistry: { search: (...args) => mockSearch(...args), getByName: jest.fn() }, - AgentInstallation: { find: jest.fn(), findOne: jest.fn() }, -})); - -const { parseVerifiedFilter } = require('../../../routes/registry/helpers'); - -// Mirrors the resolution in routes/registry/catalog.ts. -const resolveVerified = (raw) => { - const parsed = parseVerifiedFilter(raw); - return parsed === null ? true : parsed; -}; - -describe('the public catalog defaults to verified-only', () => { - test('no ?verified param → verified: true', () => { - // The leak: absent used to mean "no filter", i.e. show everything. - expect(resolveVerified(undefined)).toBe(true); - }); - - test('?verified=true → true', () => { - expect(resolveVerified('true')).toBe(true); - }); - - test('?verified=false still reaches unverified rows — capability preserved', () => { - // Nothing becomes unreachable; only the default changes. An explicit - // opt-in is how an admin or a future curated-community view gets them. - expect(resolveVerified('false')).toBe(false); - }); - - test('a garbage value falls back to the safe default rather than to "show all"', () => { - // parseVerifiedFilter returns null for anything it does not recognise, and - // null must resolve to the RESTRICTIVE side — the whole point of the fix. - expect(resolveVerified('yes')).toBe(true); - expect(resolveVerified('')).toBe(true); - }); -}); diff --git a/backend/routes/registry/catalog.ts b/backend/routes/registry/catalog.ts index 60905002a..c3e560e2b 100644 --- a/backend/routes/registry/catalog.ts +++ b/backend/routes/registry/catalog.ts @@ -46,26 +46,9 @@ catalogRouter.get('/agents', auth, async (req: any, res: any) => { q, category, verified, registry, limit = 20, offset = 0, } = req.query; - // Default to VERIFIED-ONLY. `search()` already excludes ephemeral rows, - // but that was never the leak: measured 2026-08-14, this endpoint returned - // 66 rows of which 52 were unverified, and among them every internal and - // smoke-test agent we have ever created — `smoke-claude`, `demo-target`, - // `demo-clean2`, the `smokea50698-*` family, `test-agent`, `test-agent2`, - // plus our own working seats `pod-architect`, `cl-critic`, `cl-strategist`, - // `claude-on-dev`, `sam-claude`, `sam-local-codex`, `nova-claude`, - // `hq-support`, `carol`. - // - // The landing footer links this endpoint, so a logged-out visitor could - // browse our test fixtures. `verified` is exactly the axis that separates - // them: every one of those rows is `commonly-community` + unverified, - // while the curated set is verified. - // - // An explicit `?verified=false` still works, so nothing is unreachable — - // the default just stops being "show everything we ever wrote." - const verifiedFilter = parseVerifiedFilter(verified); const agents = await AgentRegistry.search(q, { category, - verified: verifiedFilter === null ? true : verifiedFilter, + verified: parseVerifiedFilter(verified), registry: registry || null, limit: parseInt(limit, 10), offset: parseInt(offset, 10), diff --git a/backend/scripts/unlist-internal-registry-agents.ts b/backend/scripts/unlist-internal-registry-agents.ts new file mode 100644 index 000000000..4fbe87b3d --- /dev/null +++ b/backend/scripts/unlist-internal-registry-agents.ts @@ -0,0 +1,108 @@ +#!/usr/bin/env node +/* + * Mark our own internal / smoke-test registry rows `ephemeral: true` so they + * stop appearing in the public agent catalog. + * + * Measured 2026-08-14: `GET /api/registry/agents` returned 66 rows, and among + * them every internal and smoke-test agent we have ever created — the + * `smoke*`/`demo*`/`test*` families plus our own working seats + * (pod-architect, cl-critic, cl-strategist, claude-on-dev, sam-claude, + * sam-local-codex, nova-claude, hq-support, carol). The landing-page footer + * links that endpoint, so a logged-out visitor could browse the lot. + * + * Why data and not a filter. The first attempt defaulted the catalog to + * verified-only, and `self-serve-install.test.js` rejected it correctly: a + * legitimately published third-party agent is unverified too, so that filter + * would have hidden real publishers to hide our fixtures. `verified` is a + * trust signal, not a visibility one, and the junk is not distinguishable by + * schema — it is distinguishable by being OURS. + * + * `ephemeral` already means exactly "private to its owner; direct getByName + * still resolves it, marketplace browse does not" (AgentRegistry.search + * filters it, and the ADR-006 self-serve rows use it for the same reason). + * Our test seats fit that definition precisely, so this is a data correction + * rather than a new concept. + * + * Names are matched explicitly, never by pattern. A regex over agent names + * would eventually swallow a real user's agent called "demo-something" — the + * blast radius of a wrong guess here is a publisher silently delisted, so the + * list is enumerated and anything unrecognised is left alone and reported. + * + * Usage: + * npx ts-node backend/scripts/unlist-internal-registry-agents.ts # dry run + * npx ts-node backend/scripts/unlist-internal-registry-agents.ts --apply + */ + +/* eslint-disable no-console */ +const mongoose = require('mongoose'); + +const APPLY = process.argv.includes('--apply'); + +// Enumerated deliberately. Add names here rather than widening a pattern. +const INTERNAL_AGENT_NAMES = [ + // smoke + demo fixtures + 'smoke-claude', 'smoke-stub', 'smokea50698-agent', 'smokea50698-scribe', + 'smokea50698-helper', 'smokea50698-organic', + 'demo-claude', 'demo-claude2', 'demo-clean2', 'demo-target', + 'test-agent', 'test-agent2', + // our own working seats + 'pod-architect', 'cl-critic', 'cl-strategist', 'claude-on-dev', + 'sam-claude', 'sam-local-codex', 'nova-claude', 'hq-support', 'carol', +]; + +const main = async () => { + const uri = process.env.MONGO_URI; + if (!uri) { + console.error('MONGO_URI is required'); + process.exit(1); + } + await mongoose.connect(uri); + const C = mongoose.connection.collection('agentregistries'); + + const found = await C.find({ agentName: { $in: INTERNAL_AGENT_NAMES } }) + .project({ agentName: 1, ephemeral: 1, verified: 1, registry: 1 }).toArray(); + + const already = found.filter((r: any) => r.ephemeral === true); + const todo = found.filter((r: any) => r.ephemeral !== true); + const missing = INTERNAL_AGENT_NAMES.filter( + (n) => !found.some((r: any) => r.agentName === n), + ); + + console.log(`named: ${INTERNAL_AGENT_NAMES.length}`); + console.log(`found in registry: ${found.length}`); + console.log(`already ephemeral: ${already.length}`); + console.log(`to mark: ${todo.length}`); + if (missing.length) console.log(`not present (fine): ${missing.join(', ')}`); + todo.forEach((r: any) => { + console.log(` ${String(r.agentName).padEnd(22)} registry=${r.registry} verified=${r.verified}`); + }); + + // Report, never touch: anything catalog-visible that is NOT on the list. + // The point is to see what a human should look at next, not to widen scope. + const visible = await C.find({ status: 'active', ephemeral: { $ne: true } }) + .project({ agentName: 1 }).toArray(); + const remaining = visible + .map((r: any) => r.agentName) + .filter((n: string) => !INTERNAL_AGENT_NAMES.includes(n)); + console.log(`\ncatalog after this runs: ${remaining.length} rows`); + console.log(` ${remaining.join(', ')}`); + + if (!APPLY) { + console.log('\nDRY RUN — re-run with --apply to write.'); + await mongoose.disconnect(); + return; + } + + const res = await C.updateMany( + { agentName: { $in: INTERNAL_AGENT_NAMES }, ephemeral: { $ne: true } }, + { $set: { ephemeral: true } }, + ); + // Report what the DB says it changed, not what we intended. + console.log(`\nmarked ${res.modifiedCount} rows ephemeral.`); + await mongoose.disconnect(); +}; + +main().catch((err) => { + console.error(err); + process.exit(1); +});