Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/current/SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions packages/@eep-dev/gates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 65 additions & 1 deletion packages/@eep-dev/gates/src/access-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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: [] };
}

Expand All @@ -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.
*/
Expand Down
4 changes: 2 additions & 2 deletions packages/@eep-dev/gates/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
31 changes: 31 additions & 0 deletions packages/@eep-dev/gates/src/resolution-parity.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
});
36 changes: 31 additions & 5 deletions packages/@eep-dev/gates/src/resource-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
}
}
Expand Down
Loading