diff --git a/CHANGELOG.md b/CHANGELOG.md index 3817429..4f00994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ All notable changes to this repository are documented here. The format is loosel ### Security +- **`@eep-dev/gates` (TypeScript) and `eep-gates` (Python) — closed a + default-tier wildcard specificity bypass in `resolveAccess` / + `resolve_access`.** A gate configuration commonly publishes a broad scope on + the no-requirements default tier and carves out a narrower, gated path, e.g. + default `public` with `access: ["content.*", "profile.*"]` plus a `paid` tier + with `access: ["content.premium.*"]` and `requirements: [...]`. Because + `content.*` covers `content.premium.X`, the resolver granted access to the + gated resource through the default tier's broad match even when the gated + tier's requirements were unmet, so a request with no proofs read premium + content. The resolver now applies a specificity override: when the winning + tier is the default tier and a gated (requirements-bearing) tier targets the + same resource with an **equal-or-more-specific** access pattern, the default + grant is suppressed and the request is denied with the gated tier's unmet + requirements (a 402 instead of a leak). A *strictly more specific* default + pattern keeps its grant (owners can still open a narrower path explicitly), + resources no gated tier touches stay public, and resources the default tier + does not cover at all fail closed. New helpers `patternSpecificity`, + `bestSpecificityFor`, `defaultTierOverriddenByGatedTier` (TypeScript) and + `pattern_specificity`, `best_specificity_for`, + `default_tier_overridden_by_gated_tier` (Python) are exported for reuse. + Resource-less resolution and tiers satisfied by valid proofs are unchanged. + Surfaced by the EEP protocol audit. + - **`@eep-dev/middleware` (TypeScript) and `eep-middleware` (Python) — auth adapters now fail closed and verify credentials.** The `JWTAuthAdapter` previously decoded the JWT payload and emitted `did_verified` identity and diff --git a/docs/current/SPECIFICATION.md b/docs/current/SPECIFICATION.md index ee5d58b..ab04fb7 100644 --- a/docs/current/SPECIFICATION.md +++ b/docs/current/SPECIFICATION.md @@ -227,6 +227,8 @@ Proof validation is two-step: The protocol defines structural validation. Implementing platforms provide a `ProofVerifier` for semantic checks. +**Informative (non-normative) — tier matching precedence:** When more than one tier's access patterns match a requested resource, the reference implementations resolve to the **most specific** matching tier rather than the most permissive one. In particular, a `default_tier` wildcard (for example `content.*`) does not silently grant access to a resource that a gated tier protects with an equal-or-more-specific pattern (for example `content.premium.*`): an agent that has not satisfied the gated tier's requirements is denied (402) instead of falling through to the default tier. Pattern specificity ranks an exact pattern (`a.b.c`) above any scope wildcard (`a.b.*`, longer prefix more specific) above the universal wildcard (`*`). Resources that only the default tier covers stay public. The reference `@eep-dev/gates` / `eep_gates` libraries implement this as `defaultTierOverriddenByGatedTier` / `default_tier_overridden_by_gated_tier`; publishers that author their own resolvers are encouraged to follow the same precedence so a broad public tier cannot bypass a narrower gate. + --- ## 4. Layer 2: signal stream (SSE) diff --git a/packages/@eep-dev/gates/README.md b/packages/@eep-dev/gates/README.md index baec60d..1c6d2e1 100644 --- a/packages/@eep-dev/gates/README.md +++ b/packages/@eep-dev/gates/README.md @@ -98,6 +98,36 @@ await resolveAccess(proofs, gateConfig, resourcePath, registry, { }); ``` +#### Tier specificity (default-tier wildcard override) + +When resolving a specific `resource`, a more-specific gated tier always wins +over a broader default-tier wildcard. With the common shape below, an agent +with no proofs is denied `content.premium.*` (a 402 carrying the gated tier's +unmet requirements) even though the default `content.*` pattern technically +covers it: + +```typescript +const config = { + default_tier: 'public', + tiers: { + public: { requirements: [], access: ['content.*', 'profile.*'] }, + paid: { requirements: [{ type: 'trust', min_score: 20 }], access: ['content.premium.*'] }, + }, +}; + +await resolveAccess([], config, 'content.premium.x'); // { granted: false, tier: 'public', unmet: [trust] } +await resolveAccess([], config, 'content.blog.post'); // { granted: true, tier: 'public' } — public stays public +``` + +The default tier's grant is suppressed only when a gated (requirements-bearing) +tier matches the resource with an **equal-or-more-specific** access pattern. +A strictly more-specific default pattern keeps its grant, and resources the +default tier does not cover at all fail closed. Specificity ranks `"*"` below +any scope wildcard (`"a.b.*"`, longer prefix wins) below any exact pattern. The +helpers `patternSpecificity`, `bestSpecificityFor`, and +`defaultTierOverriddenByGatedTier` are exported for callers that re-implement +the hot path. + ### Proof Validation ```typescript diff --git a/packages/@eep-dev/gates/src/access-resolver.ts b/packages/@eep-dev/gates/src/access-resolver.ts index d1b1eef..197640c 100644 --- a/packages/@eep-dev/gates/src/access-resolver.ts +++ b/packages/@eep-dev/gates/src/access-resolver.ts @@ -6,7 +6,7 @@ */ import type { GateConfig, GateProof, AccessResult, UnmetRequirement, Requirement, CombinedRequirement } from './types.js'; -import { matchesAny } from './resource-matcher.js'; +import { matchesAny, bestSpecificityFor } from './resource-matcher.js'; import { validateProofStructure } from './proof-validator.js'; import type { ProofVerifierRegistry } from './proof-validator.js'; @@ -77,6 +77,24 @@ export async function resolveAccess( if (resource) { const bestTierConfig = config.tiers[bestTier]; if (bestTierConfig && matchesAny(bestTierConfig.access, resource)) { + // Specificity override. Reaching here with `bestTier` still equal to + // the default tier means no gated (requirements-bearing) tier was + // satisfied. The default tier should NOT grant the resource through + // a broad wildcard (e.g. "content.*") when a gated tier targets the + // same resource with an equal-or-more-specific pattern + // (e.g. "content.premium.*"); otherwise the broad public grant + // silently bypasses the gate. Deny and surface the gated tier's + // unmet requirements instead. + if ( + bestTier === config.default_tier && + defaultTierOverriddenByGatedTier(config, resource) + ) { + const unmet = await getUnmetForResource( + config, resource, proofsByType, verifierRegistry, + strictSemanticVerification, + ); + return { granted: false, tier: bestTier, unmet }; + } return { granted: true, tier: bestTier, unmet: [] }; } @@ -91,6 +109,52 @@ export async function resolveAccess( return { granted: true, tier: bestTier, unmet: [] }; } +/** + * Decide whether the default tier's grant of `resource` must be suppressed + * because a gated, non-default tier targets the same resource with an + * equal-or-more-specific access pattern. + * + * Gate configs are commonly shaped so the default tier publishes a broad scope + * and a gated tier carves out a more specific path: + * + * default "public": access = ["content.*", "profile.*"] + * "paid": access = ["content.premium.*"], requirements = [...] + * + * Here `content.*` in the public tier covers `content.premium.X`, so a request + * with no proofs would otherwise leak through the default tier. The owner's + * intent is "premium is more specific than public, so it gates" — this returns + * true in that case so the resolver denies and emits a 402 instead. + * + * The default's grant is suppressed when a gated tier's best matching pattern + * is at least as specific as the default tier's best match. A *strictly more + * specific* default pattern keeps its grant (the owner explicitly opened that + * narrower path), and a resource the default tier does not cover at all is + * treated as authoritatively owned by the gated tier (fail closed). + */ +export function defaultTierOverriddenByGatedTier(config: GateConfig, resource: string): boolean { + const defaultTier = config.tiers[config.default_tier]; + if (!defaultTier) return true; + + const defaultBest = bestSpecificityFor(defaultTier.access, resource); + if (defaultBest < 0) return true; + + for (const [tierKey, tier] of Object.entries(config.tiers)) { + if (tierKey === config.default_tier) continue; + // A non-default tier with no requirements is just another free tier; it + // does not gate anything, so it cannot override the default's grant. + if (!tier.requirements || tier.requirements.length === 0) continue; + + const gatedBest = bestSpecificityFor(tier.access, resource); + if (gatedBest < 0) continue; + + // At least as specific as the default's best match: the gated tier wins, + // so the default tier must not bypass its requirements. + if (gatedBest >= defaultBest) return true; + } + + return false; +} + /** * Check whether a single requirement (including nested combined) is satisfied. */ diff --git a/packages/@eep-dev/gates/src/index.ts b/packages/@eep-dev/gates/src/index.ts index 6a90f08..2e1cb2b 100644 --- a/packages/@eep-dev/gates/src/index.ts +++ b/packages/@eep-dev/gates/src/index.ts @@ -102,10 +102,10 @@ export type { export { parseGateConfig, serializeGateConfig, getUsedRequirementTypes, GateConfigError } from './gate-config.js'; // ── Resource Matching ───────────────────────────────────────────────────────── -export { matchResource, matchesAny, findTiersForResource } from './resource-matcher.js'; +export { matchResource, matchesAny, findTiersForResource, patternSpecificity, bestSpecificityFor } from './resource-matcher.js'; // ── Access Resolution ───────────────────────────────────────────────────────── -export { resolveAccess, type ResolveAccessOptions } from './access-resolver.js'; +export { resolveAccess, defaultTierOverriddenByGatedTier, type ResolveAccessOptions } from './access-resolver.js'; // ── Proof Validation ────────────────────────────────────────────────────────── export { diff --git a/packages/@eep-dev/gates/src/resolution-parity.test.ts b/packages/@eep-dev/gates/src/resolution-parity.test.ts new file mode 100644 index 0000000..7c29287 --- /dev/null +++ b/packages/@eep-dev/gates/src/resolution-parity.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { resolveAccess } from './access-resolver.js'; +import type { GateConfig } from './types.js'; + +interface ResolutionFixture { + name: string; + config: GateConfig; + resource: string | null; + expected_granted: boolean; + expected_tier: string; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const fixturePath = resolve(here, '../../../../tests/parity/gate-resolution-specificity-fixtures.json'); +const fixtures = JSON.parse(readFileSync(fixturePath, 'utf8')) as ResolutionFixture[]; + +// Cross-language parity for the default-tier wildcard specificity override. +// These cases use no proofs (deterministic; no semantic verifier needed), so +// the TS and Python resolvers must agree on granted/tier for each fixture. +describe('Gate resolution specificity parity fixtures (TS)', () => { + for (const fixture of fixtures) { + it(fixture.name, async () => { + const result = await resolveAccess([], fixture.config, fixture.resource ?? undefined); + expect(result.granted).toBe(fixture.expected_granted); + expect(result.tier).toBe(fixture.expected_tier); + }); + } +}); diff --git a/packages/@eep-dev/gates/src/resource-matcher.ts b/packages/@eep-dev/gates/src/resource-matcher.ts index 75df0b3..99865fc 100644 --- a/packages/@eep-dev/gates/src/resource-matcher.ts +++ b/packages/@eep-dev/gates/src/resource-matcher.ts @@ -41,6 +41,36 @@ export function matchesAny(patterns: string[], resource: string): boolean { return patterns.some(p => matchResource(p, resource)); } +/** + * Comparable specificity score for an access pattern (higher = more specific): + * + * "*" → 0 (universal wildcard, least specific) + * "a.b.*" → pattern.length (scope wildcard, longer prefix wins) + * "a.b.c" → pattern.length+1000 (exact literal, always beats any wildcard) + * + * The score is only meaningful for a pattern that already matches the resource; + * callers should guard with `matchResource` first (see `bestSpecificityFor`). + */ +export function patternSpecificity(pattern: string): number { + if (pattern === '*') return 0; + if (pattern.endsWith('.*')) return pattern.length; + return pattern.length + 1000; +} + +/** + * Specificity of the most specific pattern in `patterns` that matches + * `resource`, or -1 when none of the patterns match. + */ +export function bestSpecificityFor(patterns: string[], resource: string): number { + let best = -1; + for (const pattern of patterns) { + if (!matchResource(pattern, resource)) continue; + const score = patternSpecificity(pattern); + if (score > best) best = score; + } + return best; +} + /** * Find all tiers that grant access to a specific resource. * Returns tier keys sorted by specificity (exact matches first, then wildcards). @@ -54,11 +84,7 @@ export function findTiersForResource( for (const [key, tier] of Object.entries(tiers)) { for (const pattern of tier.access) { if (matchResource(pattern, resource)) { - // Higher specificity = more specific pattern - const specificity = pattern === '*' ? 0 : - pattern.endsWith('.*') ? pattern.length : - pattern.length + 1000; // Exact matches are most specific - matches.push({ key, specificity }); + matches.push({ key, specificity: patternSpecificity(pattern) }); break; // One match per tier is enough } } diff --git a/packages/@eep-dev/gates/src/specificity-override.test.ts b/packages/@eep-dev/gates/src/specificity-override.test.ts new file mode 100644 index 0000000..8ed4c18 --- /dev/null +++ b/packages/@eep-dev/gates/src/specificity-override.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect } from 'vitest'; +import { resolveAccess, defaultTierOverriddenByGatedTier } from './access-resolver.js'; +import { patternSpecificity, bestSpecificityFor } from './resource-matcher.js'; +import { ProofVerifierRegistry } from './proof-validator.js'; +import type { GateConfig, GateProof } from './types.js'; + +// ── Fixtures ──────────────────────────────────────────────────────────────── +// +// The bypass: a default (no-requirements) tier publishes a broad wildcard +// (`content.*`) that *covers* a resource which a gated tier targets with a +// strictly-more-specific pattern (`content.premium.*`). Before the fix the +// resolver granted access via the default tier's broad match even when the +// gated tier's requirements were unmet, silently bypassing the gate. + +const BYPASS_CONFIG: GateConfig = { + default_tier: 'public', + tiers: { + public: { + requirements: [], + access: ['content.*', 'profile.*'], + }, + paid: { + label: 'Premium', + requirements: [{ type: 'trust', min_score: 20 }], + access: ['content.premium.*'], + }, + }, +}; + +const EQUAL_SPECIFICITY_CONFIG: GateConfig = { + default_tier: 'public', + tiers: { + // Default lists the *same* pattern as the gated tier — public must not + // win the tie and bypass the gated tier's requirements. + public: { requirements: [], access: ['content.premium.*'] }, + paid: { + requirements: [{ type: 'trust', min_score: 20 }], + access: ['content.premium.*'], + }, + }, +}; + +function allowRegistry(types: string[] = ['trust', 'payment', 'identity', 'credential', 'connection']): ProofVerifierRegistry { + const registry = new ProofVerifierRegistry(); + for (const type of types) { + registry.register({ supportedTypes: [type], verify: async () => true }); + } + return registry; +} + +// ── resolveAccess: default-tier specificity override ────────────────────────── + +describe('resolveAccess — default-tier wildcard specificity override', () => { + it('denies a gated resource that the default tier covers via a broader wildcard (the bypass)', async () => { + const result = await resolveAccess([], BYPASS_CONFIG, 'content.premium.eep-whitepaper'); + expect(result.granted).toBe(false); + expect(result.tier).toBe('public'); + expect(result.unmet.some((u) => u.type === 'trust')).toBe(true); + }); + + it('still grants the gated resource once the gated tier requirements are met', async () => { + const proofs: GateProof[] = [{ type: 'trust', self_attested: true }]; + const result = await resolveAccess(proofs, BYPASS_CONFIG, 'content.premium.eep-whitepaper', allowRegistry()); + expect(result.granted).toBe(true); + expect(result.tier).toBe('paid'); + }); + + it('keeps non-premium content public (the default wildcard is authoritative there)', async () => { + const result = await resolveAccess([], BYPASS_CONFIG, 'content.blog.hello-world'); + expect(result.granted).toBe(true); + expect(result.tier).toBe('public'); + }); + + it('still grants resources the default tier covers and no gated tier touches', async () => { + const result = await resolveAccess([], BYPASS_CONFIG, 'profile.bio'); + expect(result.granted).toBe(true); + expect(result.tier).toBe('public'); + }); + + it('denies on an equal-specificity tie between the default and a gated tier', async () => { + const result = await resolveAccess([], EQUAL_SPECIFICITY_CONFIG, 'content.premium.x'); + expect(result.granted).toBe(false); + expect(result.tier).toBe('public'); + }); + + it('grants the equal-specificity resource once requirements are met', async () => { + const proofs: GateProof[] = [{ type: 'trust', self_attested: true }]; + const result = await resolveAccess(proofs, EQUAL_SPECIFICITY_CONFIG, 'content.premium.x', allowRegistry()); + expect(result.granted).toBe(true); + expect(result.tier).toBe('paid'); + }); + + it('does not change resource-less resolution (no override when no resource is given)', async () => { + const result = await resolveAccess([], BYPASS_CONFIG); + expect(result.granted).toBe(true); + expect(result.tier).toBe('public'); + }); +}); + +// ── patternSpecificity ──────────────────────────────────────────────────────── + +describe('patternSpecificity', () => { + it('ranks the universal wildcard as least specific', () => { + expect(patternSpecificity('*')).toBe(0); + }); + + it('ranks scope wildcards by length', () => { + expect(patternSpecificity('content.*')).toBe('content.*'.length); + expect(patternSpecificity('content.premium.*')).toBe('content.premium.*'.length); + expect(patternSpecificity('content.premium.*')).toBeGreaterThan(patternSpecificity('content.*')); + }); + + it('ranks exact patterns above any wildcard', () => { + expect(patternSpecificity('content.premium.x')).toBe('content.premium.x'.length + 1000); + expect(patternSpecificity('a')).toBeGreaterThan(patternSpecificity('verylongprefix.*')); + }); +}); + +// ── bestSpecificityFor ──────────────────────────────────────────────────────── + +describe('bestSpecificityFor', () => { + it('returns the specificity of the best matching pattern', () => { + expect(bestSpecificityFor(['content.*', 'profile.*'], 'content.premium.x')).toBe('content.*'.length); + }); + + it('prefers the more specific of several matching patterns', () => { + expect(bestSpecificityFor(['content.*', 'content.premium.*'], 'content.premium.x')).toBe('content.premium.*'.length); + }); + + it('keeps the higher score when a later matching pattern is less specific', () => { + // First match is most specific; a later, broader match must not lower it. + expect(bestSpecificityFor(['content.premium.*', 'content.*'], 'content.premium.x')).toBe('content.premium.*'.length); + }); + + it('returns -1 when no pattern matches', () => { + expect(bestSpecificityFor(['profile.*', 'video.*'], 'content.premium.x')).toBe(-1); + }); + + it('returns -1 for an empty pattern list', () => { + expect(bestSpecificityFor([], 'content.premium.x')).toBe(-1); + }); +}); + +// ── defaultTierOverriddenByGatedTier ────────────────────────────────────────── + +describe('defaultTierOverriddenByGatedTier', () => { + it('is true when a gated tier targets the resource more specifically than the default', () => { + expect(defaultTierOverriddenByGatedTier(BYPASS_CONFIG, 'content.premium.x')).toBe(true); + }); + + it('is true on an equal-specificity tie with a gated tier', () => { + expect(defaultTierOverriddenByGatedTier(EQUAL_SPECIFICITY_CONFIG, 'content.premium.x')).toBe(true); + }); + + it('is false when no gated tier covers the resource', () => { + expect(defaultTierOverriddenByGatedTier(BYPASS_CONFIG, 'content.blog.post')).toBe(false); + }); + + it('fails closed when the configured default tier is missing from tiers', () => { + const config = { + default_tier: 'ghost', + tiers: { + paid: { requirements: [{ type: 'trust', min_score: 20 }], access: ['content.premium.*'] }, + }, + } as unknown as GateConfig; + expect(defaultTierOverriddenByGatedTier(config, 'content.premium.x')).toBe(true); + }); + + it('fails closed when the default tier does not cover the resource at all', () => { + const config: GateConfig = { + default_tier: 'public', + tiers: { + public: { requirements: [], access: ['profile.*'] }, + paid: { requirements: [{ type: 'trust', min_score: 20 }], access: ['content.premium.*'] }, + }, + }; + expect(defaultTierOverriddenByGatedTier(config, 'content.premium.x')).toBe(true); + }); + + it('ignores non-default tiers that have no requirements (free tiers do not gate)', () => { + const config: GateConfig = { + default_tier: 'public', + tiers: { + public: { requirements: [], access: ['content.*'] }, + open: { requirements: [], access: ['content.premium.*'] }, + }, + }; + expect(defaultTierOverriddenByGatedTier(config, 'content.premium.x')).toBe(false); + }); + + it('ignores non-default tiers whose requirements field is absent', () => { + const config = { + default_tier: 'public', + tiers: { + public: { requirements: [], access: ['content.*'] }, + weird: { access: ['content.premium.*'] }, + }, + } as unknown as GateConfig; + expect(defaultTierOverriddenByGatedTier(config, 'content.premium.x')).toBe(false); + }); + + it('ignores gated tiers that do not cover the resource', () => { + const config: GateConfig = { + default_tier: 'public', + tiers: { + public: { requirements: [], access: ['content.*'] }, + paid: { requirements: [{ type: 'trust', min_score: 20 }], access: ['video.*'] }, + }, + }; + expect(defaultTierOverriddenByGatedTier(config, 'content.premium.x')).toBe(false); + }); + + it('is false when the default tier is itself more specific than the gated tier', () => { + const config: GateConfig = { + default_tier: 'public', + tiers: { + public: { requirements: [], access: ['content.premium.docs.*'] }, + paid: { requirements: [{ type: 'trust', min_score: 20 }], access: ['content.*'] }, + }, + }; + expect(defaultTierOverriddenByGatedTier(config, 'content.premium.docs.readme')).toBe(false); + }); +}); diff --git a/packages/eep-gates-python/README.md b/packages/eep-gates-python/README.md index fdc734d..cd56428 100644 --- a/packages/eep-gates-python/README.md +++ b/packages/eep-gates-python/README.md @@ -50,6 +50,35 @@ if not result.granted: return JSONResponse(resp, status_code=402) ``` +### Tier specificity (default-tier wildcard override) + +When resolving a specific resource, a more-specific gated tier always wins over +a broader default-tier wildcard. With the common shape below, an agent with no +proofs is denied `content.premium.*` (a 402 carrying the gated tier's unmet +requirements) even though the default `content.*` pattern covers it, while +plain `content.*` resources stay public: + +```python +config = { + "default_tier": "public", + "tiers": { + "public": {"requirements": [], "access": ["content.*", "profile.*"]}, + "paid": {"requirements": [{"type": "trust", "min_score": 20}], "access": ["content.premium.*"]}, + }, +} + +await resolve_access([], config, "content.premium.x") # granted=False, tier="public", unmet=[trust] +await resolve_access([], config, "content.blog.post") # granted=True, tier="public" +``` + +The default tier's grant is suppressed only when a gated (requirements-bearing) +tier matches the resource with an **equal-or-more-specific** access pattern. A +strictly more-specific default pattern keeps its grant, and resources the +default tier does not cover at all fail closed. The helpers +`pattern_specificity`, `best_specificity_for`, and +`default_tier_overridden_by_gated_tier` are exported for callers that +re-implement the hot path. + ## Modules | Module | Description | diff --git a/packages/eep-gates-python/eep_gates/__init__.py b/packages/eep-gates-python/eep_gates/__init__.py index 60f1a04..6a39fc4 100644 --- a/packages/eep-gates-python/eep_gates/__init__.py +++ b/packages/eep-gates-python/eep_gates/__init__.py @@ -29,10 +29,13 @@ from .gate_config import parse_gate_config, serialize_gate_config, get_used_requirement_types, GateConfigError # ── Resource Matching ────────────────────────────────────────────────────────── -from .resource_matcher import match_resource, matches_any, find_tiers_for_resource +from .resource_matcher import ( + match_resource, matches_any, find_tiers_for_resource, + pattern_specificity, best_specificity_for, +) # ── Access Resolution ────────────────────────────────────────────────────────── -from .access_resolver import resolve_access +from .access_resolver import resolve_access, default_tier_overridden_by_gated_tier # ── Proof Validation ─────────────────────────────────────────────────────────── from .proof_validator import ( @@ -67,8 +70,9 @@ "parse_gate_config", "serialize_gate_config", "get_used_requirement_types", "GateConfigError", # Resource Matching "match_resource", "matches_any", "find_tiers_for_resource", + "pattern_specificity", "best_specificity_for", # Access Resolution - "resolve_access", + "resolve_access", "default_tier_overridden_by_gated_tier", # Proof Validation "validate_proof_structure", "validate_proofs", "delegation_permits_data_request", "ProofVerifier", "ProofVerifierRegistry", diff --git a/packages/eep-gates-python/eep_gates/access_resolver.py b/packages/eep-gates-python/eep_gates/access_resolver.py index e76713e..6784417 100644 --- a/packages/eep-gates-python/eep_gates/access_resolver.py +++ b/packages/eep-gates-python/eep_gates/access_resolver.py @@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional from .models import AccessResult, UnmetRequirement -from .resource_matcher import matches_any +from .resource_matcher import matches_any, best_specificity_for from .proof_validator import validate_proof_structure, ProofVerifierRegistry @@ -64,6 +64,19 @@ async def resolve_access( if best_tier_obj: bt_access = best_tier_obj.access if hasattr(best_tier_obj, "access") else best_tier_obj.get("access", []) if matches_any(bt_access, resource): + # Specificity override. Reaching here with ``best_tier`` still + # equal to the default tier means no gated (requirements-bearing) + # tier was satisfied. The default tier must NOT grant the resource + # through a broad wildcard (e.g. "content.*") when a gated tier + # targets the same resource with an equal-or-more-specific pattern + # (e.g. "content.premium.*"); otherwise the broad public grant + # silently bypasses the gate. Deny and surface the gated tier's + # unmet requirements instead. + if best_tier == default_tier and default_tier_overridden_by_gated_tier(config, resource): + unmet_for_resource = await _get_unmet_for_resource( + config, resource, proofs_by_type, verifier_registry, strict_semantic_verification + ) + return AccessResult(granted=False, tier=best_tier, unmet=unmet_for_resource) return AccessResult(granted=True, tier=best_tier, unmet=[]) unmet_for_resource = await _get_unmet_for_resource( @@ -74,6 +87,62 @@ async def resolve_access( return AccessResult(granted=True, tier=best_tier, unmet=[]) +def default_tier_overridden_by_gated_tier(config: Any, resource: str) -> bool: + """Decide whether the default tier's grant of ``resource`` must be suppressed + because a gated, non-default tier targets the same resource with an + equal-or-more-specific access pattern. + + Gate configs are commonly shaped so the default tier publishes a broad scope + and a gated tier carves out a more specific path:: + + default "public": access = ["content.*", "profile.*"] + "paid": access = ["content.premium.*"], requirements = [...] + + Here ``content.*`` in the public tier covers ``content.premium.X``, so a + request with no proofs would otherwise leak through the default tier. The + owner's intent is "premium is more specific than public, so it gates" — this + returns ``True`` in that case so the resolver denies and emits a 402 instead. + + The default's grant is suppressed when a gated tier's best matching pattern + is at least as specific as the default tier's best match. A *strictly more + specific* default pattern keeps its grant (the owner explicitly opened that + narrower path), and a resource the default tier does not cover at all is + treated as authoritatively owned by the gated tier (fail closed). + """ + tiers = config.tiers if hasattr(config, "tiers") else config.get("tiers", {}) + default_tier = config.default_tier if hasattr(config, "default_tier") else config.get("default_tier", "") + + default_tier_obj = tiers.get(default_tier) + if not default_tier_obj: + return True + + default_access = default_tier_obj.access if hasattr(default_tier_obj, "access") else default_tier_obj.get("access", []) + default_best = best_specificity_for(default_access, resource) + if default_best < 0: + return True + + for tier_key, tier in tiers.items(): + if tier_key == default_tier: + continue + # A non-default tier with no requirements is just another free tier; it + # does not gate anything, so it cannot override the default's grant. + tier_reqs = tier.requirements if hasattr(tier, "requirements") else tier.get("requirements", []) + if not tier_reqs: + continue + + tier_access = tier.access if hasattr(tier, "access") else tier.get("access", []) + gated_best = best_specificity_for(tier_access, resource) + if gated_best < 0: + continue + + # At least as specific as the default's best match: the gated tier wins, + # so the default tier must not bypass its requirements. + if gated_best >= default_best: + return True + + return False + + async def _check_one_requirement( req: Dict[str, Any], proofs_by_type: Dict[str, List[Dict[str, Any]]], diff --git a/packages/eep-gates-python/eep_gates/resource_matcher.py b/packages/eep-gates-python/eep_gates/resource_matcher.py index 1e5ba5b..7614591 100644 --- a/packages/eep-gates-python/eep_gates/resource_matcher.py +++ b/packages/eep-gates-python/eep_gates/resource_matcher.py @@ -30,6 +30,36 @@ def matches_any(patterns: List[str], resource: str) -> bool: return any(match_resource(p, resource) for p in patterns) +def pattern_specificity(pattern: str) -> int: + """Comparable specificity score for an access pattern (higher = more specific). + + "*" -> 0 (universal wildcard, least specific) + "a.b.*" -> len(pattern) (scope wildcard, longer prefix wins) + "a.b.c" -> len(pattern) + 1000 (exact literal, always beats a wildcard) + + The score is only meaningful for a pattern that already matches the resource; + callers should guard with ``match_resource`` first (see ``best_specificity_for``). + """ + if pattern == "*": + return 0 + if pattern.endswith(".*"): + return len(pattern) + return len(pattern) + 1000 + + +def best_specificity_for(patterns: List[str], resource: str) -> int: + """Specificity of the most specific pattern in ``patterns`` that matches + ``resource``, or ``-1`` when none of the patterns match.""" + best = -1 + for pattern in patterns: + if not match_resource(pattern, resource): + continue + score = pattern_specificity(pattern) + if score > best: + best = score + return best + + def find_tiers_for_resource( tiers: Dict[str, Dict], resource: str, @@ -44,13 +74,7 @@ def find_tiers_for_resource( access = tier.get("access", []) if isinstance(tier, dict) else tier.access for pattern in access: if match_resource(pattern, resource): - if pattern == "*": - specificity = 0 - elif pattern.endswith(".*"): - specificity = len(pattern) - else: - specificity = len(pattern) + 1000 - matches.append((key, specificity)) + matches.append((key, pattern_specificity(pattern))) break matches.sort(key=lambda m: m[1], reverse=True) diff --git a/packages/eep-gates-python/tests/test_all.py b/packages/eep-gates-python/tests/test_all.py index 242e2b8..35ce625 100644 --- a/packages/eep-gates-python/tests/test_all.py +++ b/packages/eep-gates-python/tests/test_all.py @@ -5,12 +5,13 @@ from eep_gates import ( # Resource matcher match_resource, matches_any, find_tiers_for_resource, + pattern_specificity, best_specificity_for, # Gate config parse_gate_config, serialize_gate_config, GateConfigError, # Proof validator validate_proof_structure, validate_proofs, ProofVerifier, ProofVerifierRegistry, # Access resolver - resolve_access, + resolve_access, default_tier_overridden_by_gated_tier, # HTTP 402 build_402_response, is_gated_resource, # Commerce @@ -366,3 +367,197 @@ def test_rejects_out_of_range_score(self): "service_id": "svc_test", }) assert r.valid is False + + +# ── Default-tier wildcard specificity override ──────────────────────────────── +# +# The bypass: a default (no-requirements) tier publishes a broad wildcard +# (``content.*``) that *covers* a resource a gated tier targets with a +# strictly-more-specific pattern (``content.premium.*``). Before the fix the +# resolver granted access via the default tier's broad match even when the +# gated tier's requirements were unmet, silently bypassing the gate. + +BYPASS_CONFIG = { + "default_tier": "public", + "tiers": { + "public": {"requirements": [], "access": ["content.*", "profile.*"]}, + "paid": { + "label": "Premium", + "requirements": [{"type": "trust", "min_score": 20}], + "access": ["content.premium.*"], + }, + }, +} + +EQUAL_SPECIFICITY_CONFIG = { + "default_tier": "public", + "tiers": { + "public": {"requirements": [], "access": ["content.premium.*"]}, + "paid": { + "requirements": [{"type": "trust", "min_score": 20}], + "access": ["content.premium.*"], + }, + }, +} + + +class _AllowVerifier(ProofVerifier): + @property + def supported_types(self): + return ["trust", "payment", "identity", "credential", "connection"] + + async def verify(self, proof, requirement): + return True + + +def _allow_registry() -> ProofVerifierRegistry: + registry = ProofVerifierRegistry() + registry.register(_AllowVerifier()) + return registry + + +class TestPatternSpecificity: + def test_universal_wildcard_is_least_specific(self): + assert pattern_specificity("*") == 0 + + def test_scope_wildcards_ranked_by_length(self): + assert pattern_specificity("content.*") == len("content.*") + assert pattern_specificity("content.premium.*") == len("content.premium.*") + assert pattern_specificity("content.premium.*") > pattern_specificity("content.*") + + def test_exact_patterns_beat_wildcards(self): + assert pattern_specificity("content.premium.x") == len("content.premium.x") + 1000 + assert pattern_specificity("a") > pattern_specificity("verylongprefix.*") + + +class TestBestSpecificityFor: + def test_returns_best_matching_specificity(self): + assert best_specificity_for(["content.*", "profile.*"], "content.premium.x") == len("content.*") + + def test_prefers_more_specific_match(self): + assert best_specificity_for(["content.*", "content.premium.*"], "content.premium.x") == len("content.premium.*") + + def test_keeps_higher_score_when_later_pattern_is_less_specific(self): + # First matching pattern is the most specific; a later, broader match + # must not lower the running best (exercises the score<=best branch). + assert best_specificity_for(["content.premium.*", "content.*"], "content.premium.x") == len("content.premium.*") + + def test_returns_negative_one_when_nothing_matches(self): + assert best_specificity_for(["profile.*", "video.*"], "content.premium.x") == -1 + + def test_returns_negative_one_for_empty_list(self): + assert best_specificity_for([], "content.premium.x") == -1 + + +class TestDefaultTierOverriddenByGatedTier: + def test_more_specific_gated_tier_overrides(self): + assert default_tier_overridden_by_gated_tier(BYPASS_CONFIG, "content.premium.x") is True + + def test_equal_specificity_tie_overrides(self): + assert default_tier_overridden_by_gated_tier(EQUAL_SPECIFICITY_CONFIG, "content.premium.x") is True + + def test_no_gated_tier_covers_resource(self): + assert default_tier_overridden_by_gated_tier(BYPASS_CONFIG, "content.blog.post") is False + + def test_missing_default_tier_fails_closed(self): + config = { + "default_tier": "ghost", + "tiers": { + "paid": {"requirements": [{"type": "trust", "min_score": 20}], "access": ["content.premium.*"]}, + }, + } + assert default_tier_overridden_by_gated_tier(config, "content.premium.x") is True + + def test_default_tier_not_covering_resource_fails_closed(self): + config = { + "default_tier": "public", + "tiers": { + "public": {"requirements": [], "access": ["profile.*"]}, + "paid": {"requirements": [{"type": "trust", "min_score": 20}], "access": ["content.premium.*"]}, + }, + } + assert default_tier_overridden_by_gated_tier(config, "content.premium.x") is True + + def test_free_non_default_tier_does_not_override(self): + config = { + "default_tier": "public", + "tiers": { + "public": {"requirements": [], "access": ["content.*"]}, + "open": {"requirements": [], "access": ["content.premium.*"]}, + }, + } + assert default_tier_overridden_by_gated_tier(config, "content.premium.x") is False + + def test_tier_without_requirements_field_does_not_override(self): + config = { + "default_tier": "public", + "tiers": { + "public": {"requirements": [], "access": ["content.*"]}, + "weird": {"access": ["content.premium.*"]}, + }, + } + assert default_tier_overridden_by_gated_tier(config, "content.premium.x") is False + + def test_gated_tier_not_covering_resource_is_ignored(self): + config = { + "default_tier": "public", + "tiers": { + "public": {"requirements": [], "access": ["content.*"]}, + "paid": {"requirements": [{"type": "trust", "min_score": 20}], "access": ["video.*"]}, + }, + } + assert default_tier_overridden_by_gated_tier(config, "content.premium.x") is False + + def test_more_specific_default_tier_keeps_grant(self): + config = { + "default_tier": "public", + "tiers": { + "public": {"requirements": [], "access": ["content.premium.docs.*"]}, + "paid": {"requirements": [{"type": "trust", "min_score": 20}], "access": ["content.*"]}, + }, + } + assert default_tier_overridden_by_gated_tier(config, "content.premium.docs.readme") is False + + def test_parsed_model_config_overrides(self): + config = parse_gate_config(BYPASS_CONFIG) + assert default_tier_overridden_by_gated_tier(config, "content.premium.x") is True + + +class TestSpecificityOverrideResolution: + @pytest.mark.asyncio + async def test_bypass_resource_is_denied(self): + result = await resolve_access([], BYPASS_CONFIG, "content.premium.eep-whitepaper") + assert result.granted is False + assert result.tier == "public" + assert any(u.type == "trust" for u in result.unmet) + + @pytest.mark.asyncio + async def test_bypass_resource_granted_when_requirements_met(self): + proofs = [{"type": "trust", "self_attested": True}] + result = await resolve_access(proofs, BYPASS_CONFIG, "content.premium.eep-whitepaper", verifier_registry=_allow_registry()) + assert result.granted is True + assert result.tier == "paid" + + @pytest.mark.asyncio + async def test_non_premium_content_stays_public(self): + result = await resolve_access([], BYPASS_CONFIG, "content.blog.hello-world") + assert result.granted is True + assert result.tier == "public" + + @pytest.mark.asyncio + async def test_default_only_resource_still_granted(self): + result = await resolve_access([], BYPASS_CONFIG, "profile.bio") + assert result.granted is True + assert result.tier == "public" + + @pytest.mark.asyncio + async def test_equal_specificity_tie_is_denied(self): + result = await resolve_access([], EQUAL_SPECIFICITY_CONFIG, "content.premium.x") + assert result.granted is False + assert result.tier == "public" + + @pytest.mark.asyncio + async def test_resource_less_resolution_unchanged(self): + result = await resolve_access([], BYPASS_CONFIG) + assert result.granted is True + assert result.tier == "public" diff --git a/packages/eep-gates-python/tests/test_resolution_parity.py b/packages/eep-gates-python/tests/test_resolution_parity.py new file mode 100644 index 0000000..94fd1c6 --- /dev/null +++ b/packages/eep-gates-python/tests/test_resolution_parity.py @@ -0,0 +1,43 @@ +# Copyright 2026 EEP Contributors — Apache-2.0 +"""Cross-language parity fixtures for the default-tier specificity override. + +These cases use no proofs (deterministic; no semantic verifier needed), so the +Python and TypeScript resolvers must agree on granted/tier for each fixture. +The shared fixture file is also consumed by the TypeScript suite +(``packages/@eep-dev/gates/src/resolution-parity.test.ts``). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from eep_gates import resolve_access + + +def _fixtures(): + fixture_path = ( + Path(__file__).resolve().parents[3] + / "tests" + / "parity" + / "gate-resolution-specificity-fixtures.json" + ) + return json.loads(fixture_path.read_text(encoding="utf-8")) + + +@pytest.mark.asyncio +async def test_gate_resolution_specificity_parity_fixtures(): + fixtures = _fixtures() + assert fixtures, "expected at least one resolution parity fixture" + for fixture in fixtures: + result = await resolve_access([], fixture["config"], fixture["resource"]) + assert result.granted == fixture["expected_granted"], ( + f"fixture={fixture['name']} granted expected={fixture['expected_granted']} " + f"got={result.granted}" + ) + assert result.tier == fixture["expected_tier"], ( + f"fixture={fixture['name']} tier expected={fixture['expected_tier']} " + f"got={result.tier}" + ) diff --git a/tests/parity/gate-resolution-specificity-fixtures.json b/tests/parity/gate-resolution-specificity-fixtures.json new file mode 100644 index 0000000..ad88d89 --- /dev/null +++ b/tests/parity/gate-resolution-specificity-fixtures.json @@ -0,0 +1,119 @@ +[ + { + "name": "bypass_premium_denied", + "config": { + "default_tier": "public", + "tiers": { + "public": { "requirements": [], "access": ["content.*", "profile.*"] }, + "paid": { "requirements": [{ "type": "trust", "min_score": 20 }], "access": ["content.premium.*"] } + } + }, + "resource": "content.premium.eep-whitepaper", + "expected_granted": false, + "expected_tier": "public" + }, + { + "name": "non_premium_content_stays_public", + "config": { + "default_tier": "public", + "tiers": { + "public": { "requirements": [], "access": ["content.*", "profile.*"] }, + "paid": { "requirements": [{ "type": "trust", "min_score": 20 }], "access": ["content.premium.*"] } + } + }, + "resource": "content.blog.hello-world", + "expected_granted": true, + "expected_tier": "public" + }, + { + "name": "default_only_resource_granted", + "config": { + "default_tier": "public", + "tiers": { + "public": { "requirements": [], "access": ["content.*", "profile.*"] }, + "paid": { "requirements": [{ "type": "trust", "min_score": 20 }], "access": ["content.premium.*"] } + } + }, + "resource": "profile.bio", + "expected_granted": true, + "expected_tier": "public" + }, + { + "name": "equal_specificity_tie_denied", + "config": { + "default_tier": "public", + "tiers": { + "public": { "requirements": [], "access": ["content.premium.*"] }, + "paid": { "requirements": [{ "type": "trust", "min_score": 20 }], "access": ["content.premium.*"] } + } + }, + "resource": "content.premium.x", + "expected_granted": false, + "expected_tier": "public" + }, + { + "name": "more_specific_default_keeps_grant", + "config": { + "default_tier": "public", + "tiers": { + "public": { "requirements": [], "access": ["content.premium.docs.*"] }, + "paid": { "requirements": [{ "type": "trust", "min_score": 20 }], "access": ["content.*"] } + } + }, + "resource": "content.premium.docs.readme", + "expected_granted": true, + "expected_tier": "public" + }, + { + "name": "default_not_covering_resource_fails_closed", + "config": { + "default_tier": "public", + "tiers": { + "public": { "requirements": [], "access": ["profile.*"] }, + "paid": { "requirements": [{ "type": "trust", "min_score": 20 }], "access": ["content.premium.*"] } + } + }, + "resource": "content.premium.x", + "expected_granted": false, + "expected_tier": "public" + }, + { + "name": "gated_tier_not_covering_resource_ignored", + "config": { + "default_tier": "public", + "tiers": { + "public": { "requirements": [], "access": ["content.*"] }, + "paid": { "requirements": [{ "type": "trust", "min_score": 20 }], "access": ["video.*"] } + } + }, + "resource": "content.premium.x", + "expected_granted": true, + "expected_tier": "public" + }, + { + "name": "free_non_default_tier_does_not_gate", + "config": { + "default_tier": "public", + "tiers": { + "public": { "requirements": [], "access": ["content.*"] }, + "open": { "requirements": [], "access": ["content.premium.*"] } + } + }, + "resource": "content.premium.x", + "expected_granted": true, + "expected_tier": "public" + }, + { + "name": "resource_less_resolution_granted_default", + "config": { + "default_tier": "public", + "tiers": { + "public": { "requirements": [], "access": ["content.*", "profile.*"] }, + "paid": { "requirements": [{ "type": "trust", "min_score": 20 }], "access": ["content.premium.*"] } + } + }, + "resource": null, + "expected_granted": true, + "expected_tier": "public" + } +]