diff --git a/site/public/_headers b/site/public/_headers new file mode 100644 index 0000000..597c55b --- /dev/null +++ b/site/public/_headers @@ -0,0 +1,6 @@ +/* + Content-Security-Policy: base-uri 'self'; frame-ancestors 'none'; object-src 'none' + X-Content-Type-Options: nosniff + X-Frame-Options: DENY + Referrer-Policy: strict-origin-when-cross-origin + Permissions-Policy: camera=(), geolocation=(), microphone=(), payment=(), usb=() diff --git a/site/src/lib/catalog.test.ts b/site/src/lib/catalog.test.ts index cef994f..e6ab8b3 100644 --- a/site/src/lib/catalog.test.ts +++ b/site/src/lib/catalog.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { PRICE_CATALOG, supplementPriceCandidates } from './catalog'; +import { + isPinnedPriceCandidate, + PRICE_CATALOG, + supplementPriceCandidates, +} from './catalog'; describe('price candidate discovery', () => { - it('keeps resolver data and adds missing known sources for trust ranking', () => { + it('pins known resolver identities and adds missing sources for trust ranking', () => { const resolvedCoinGecko = { ...PRICE_CATALOG[1], why: 'Returned by the live resolver.', @@ -15,7 +19,10 @@ describe('price candidate discovery', () => { 'nodary', 'tickerlayer', ]); - expect(candidates[0]).toBe(resolvedCoinGecko); + expect(candidates[0]).toEqual({ + ...PRICE_CATALOG[1], + origin: 'resolver', + }); }); it('marks supplemented sources as demo additions rather than discoveries', () => { @@ -28,4 +35,24 @@ describe('price candidate discovery', () => { 'demo-catalog', ]); }); + + it('does not let the resolver redefine a known source identity', () => { + const spoofedNodary = { + ...PRICE_CATALOG[0], + airnode: 'https://example.invalid/', + address: '0x1111111111111111111111111111111111111111' as const, + attestation: 'third-party' as const, + origin: 'resolver' as const, + }; + + const [candidate] = supplementPriceCandidates([spoofedNodary]); + + expect(isPinnedPriceCandidate(spoofedNodary)).toBe(false); + expect(isPinnedPriceCandidate(candidate)).toBe(true); + expect(candidate).toMatchObject({ + airnode: PRICE_CATALOG[0].airnode, + address: PRICE_CATALOG[0].address, + attestation: PRICE_CATALOG[0].attestation, + }); + }); }); diff --git a/site/src/lib/catalog.ts b/site/src/lib/catalog.ts index 2cb8c8e..57afc46 100644 --- a/site/src/lib/catalog.ts +++ b/site/src/lib/catalog.ts @@ -58,22 +58,42 @@ export const PRICE_CATALOG: Candidate[] = [ }, ]; -export const SUPPORTED_LISTINGS = new Set( - PRICE_CATALOG.map((candidate) => candidate.listing), +function candidateKey( + candidate: Pick, +): string { + return `${candidate.listing}:${candidate.operation}`; +} + +const PRICE_CATALOG_BY_KEY = new Map( + PRICE_CATALOG.map((candidate) => [candidateKey(candidate), candidate]), ); export function supplementPriceCandidates(candidates: Candidate[]): Candidate[] { + const pinnedCandidates = candidates.map((candidate) => { + const pinned = PRICE_CATALOG_BY_KEY.get(candidateKey(candidate)); + return pinned ? { ...pinned, origin: 'resolver' as const } : candidate; + }); const discovered = new Set( - candidates.map((candidate) => `${candidate.listing}:${candidate.operation}`), + pinnedCandidates.map(candidateKey), ); return [ - ...candidates, + ...pinnedCandidates, ...PRICE_CATALOG.filter( - (candidate) => !discovered.has(`${candidate.listing}:${candidate.operation}`), + (candidate) => !discovered.has(candidateKey(candidate)), ), ]; } +export function isPinnedPriceCandidate(candidate: Candidate): boolean { + const pinned = PRICE_CATALOG_BY_KEY.get(candidateKey(candidate)); + return Boolean( + pinned && + candidate.airnode === pinned.airnode && + candidate.address.toLowerCase() === pinned.address.toLowerCase() && + candidate.attestation === pinned.attestation, + ); +} + export function supportsCatalogFallback(intent: string): boolean { const normalized = intent.toLowerCase(); const asksForEth = /\b(eth|ethereum)\b/.test(normalized); diff --git a/site/src/lib/client.ts b/site/src/lib/client.ts index 1f6da2e..979c8f2 100644 --- a/site/src/lib/client.ts +++ b/site/src/lib/client.ts @@ -126,7 +126,8 @@ async function discover( ...candidate, origin: 'resolver', })); - const candidates = supportsCatalogFallback(intent) + const hasPinnedPriceCatalog = supportsCatalogFallback(intent); + const candidates = hasPinnedPriceCatalog ? supplementPriceCandidates(resolved) : resolved; const addedCount = candidates.length - resolved.length; @@ -138,7 +139,9 @@ async function discover( ? `${resolved.length} discovered, ${addedCount} added by the demo` : `${resolved.length} candidates discovered`, addedCount - ? 'The resolver returned the discovered sources. The demo added its own known ETH/USD sources so the trust policy can rank them, and marks them separately.' + ? 'Resolver matches use identities pinned by the demo. Missing known ETH/USD sources were added so the trust policy can rank them.' + : hasPinnedPriceCatalog + ? 'The resolver matched known ETH/USD sources; endpoint, signer, and provenance identities remain pinned by the demo.' : 'Parameters, provenance, signer, and payment requirements came from the resolver.', 'success', ); diff --git a/site/src/lib/policy.ts b/site/src/lib/policy.ts index 34ae289..bcbe8e7 100644 --- a/site/src/lib/policy.ts +++ b/site/src/lib/policy.ts @@ -1,4 +1,4 @@ -import { SUPPORTED_LISTINGS } from './catalog'; +import { isPinnedPriceCandidate } from './catalog'; import type { Candidate, CandidateDecision, @@ -43,12 +43,12 @@ export function evaluateCandidates( const rejected: CandidateDecision[] = []; for (const candidate of unique.values()) { - if (!SUPPORTED_LISTINGS.has(candidate.listing)) { + if (!isPinnedPriceCandidate(candidate)) { rejected.push({ listing: candidate.listing, provenance: candidate.attestation, status: 'rejected', - reason: 'This MVP has no price adapter for the listing.', + reason: 'This MVP has no pinned identity for the listing and operation.', rank: null, candidate, }); diff --git a/use-cases/trust-aware-agent/README.md b/use-cases/trust-aware-agent/README.md index d3e8e5e..6f6659a 100644 --- a/use-cases/trust-aware-agent/README.md +++ b/use-cases/trust-aware-agent/README.md @@ -34,7 +34,7 @@ Open the local URL printed by Vite and select **Trust-Aware Agent**. No API key 4. It calls the Airnode directly and verifies the request hash, response bytes, EIP-191 signature, signer, freshness, and normalized price. 5. Only a verified value is released with a portable receipt. -The resolver may return a retryable `503` during early access. After two bounded attempts, the demo uses its clearly labelled price catalog, still checks the selected Airnode's live document, and still verifies the live response. +The resolver may return a retryable `503` during early access. After two bounded attempts, the demo uses its clearly labelled price catalog, still checks the selected Airnode's live document, and still verifies the live response. For known price operations, that catalog pins the endpoint, signer, and provenance so a resolver response cannot redefine a trusted source identity. The agent-ready prompt is [`site/public/prompts/trust-aware-agent.md`](../../site/public/prompts/trust-aware-agent.md). The framework-neutral tool contract is [`site/examples/agent-tool.json`](../../site/examples/agent-tool.json).