Skip to content
Closed
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

- `trusted_endpoints`: registered URLs may now contain FastAPI/Express-style path placeholders. `{id}` matches exactly one path segment, `{rest:path}` matches any subtree. Plain URLs without `{` keep exact-match semantics — no migration needed for existing rows. Both `is_trusted_endpoint` and the snapshot tamper-check inside `evaluate_handoff` honor the new syntax. Closes #14.
- README: new "Getting `PROVABLY_API_KEY` and `PROVABLY_ORG_ID`" subsection walking through sign-up at app.provably.ai → create org → Integrations menu, plus a pointer to provably.ai/docs.
- **BREAKING:** removed `default_cluster_b_url()` and the `CLUSTER_B_URL` env var — leftovers from the langgraph-demo monorepo extraction with a `localhost:8082` default and opaque "cluster B" naming the SDK has no business assuming. `post_handoff(receiver_url, payload)` (positional arg renamed from `cluster_b_url`) takes the URL directly — supply it from your application's own configuration.

## 0.3.0

### aiohttp interception (soft dependency)
Expand Down
40 changes: 36 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,14 +201,21 @@ The SDK reads configuration from environment variables. A typed
`Provably(api_key=..., org_id=..., ...)` client that replaces these globals is
planned (issue [#2](https://github.com/ProvablyAI/provably-python-sdk/issues/2)).

#### Getting `PROVABLY_API_KEY` and `PROVABLY_ORG_ID`

1. Sign up at [app.provably.ai](https://app.provably.ai).
2. Create an organisation. Its id is what goes in `PROVABLY_ORG_ID`.
3. In the left-side menu, go to **Integrations** and create one. The generated key is your `PROVABLY_API_KEY`.

Full product docs: [provably.ai/docs](https://provably.ai/docs).

| Variable | Used by | Required |
|---|---|---|
| `PROVABLY_API_KEY` | `initialize_runtime`, integration cache | yes |
| `PROVABLY_ORG_ID` | `initialize_runtime`, intercept allow-list | yes |
| `PROVABLY_RUST_BE_URL` | `initialize_runtime`, evaluator | yes |
| `POSTGRES_URL` | intercept storage, trusted endpoints, handoff preprocess | yes |
| `PROVABLY_APP_UI_URL` | optional UI deep-links | no |
| `CLUSTER_B_URL` | `default_cluster_b_url()` helper only | no |
| `PROVABLY_QUERY_RESOLVE_MAX_WAIT_S` | max seconds to wait for a query record to appear (default 15) | no |

`POSTGRES_URL` is a hard dependency today. Three SDK modules open Postgres
Expand Down Expand Up @@ -299,11 +306,11 @@ on any non-2xx response.
interceptor's in-memory state — no manual claim construction needed:

```python
from provably import build_handoff_payload, post_handoff, default_cluster_b_url
from provably import build_handoff_payload, post_handoff

# fetch_and_claim is the raw JSON dict the LLM emitted
payload = build_handoff_payload(fetch_and_claim, run_id="run-001")
post_handoff(default_cluster_b_url(), payload)
post_handoff("https://your-verifier.example.com", payload)
```

`claim_contract` generates the system-prompt text that tells an LLM how to
Expand Down Expand Up @@ -396,6 +403,31 @@ URLs are normalized (lowercase scheme + host, default ports collapsed, trailing
slash dropped) before any read or write so that `https://API.EXAMPLE.COM/x/`
and `https://api.example.com/x` collide on the same row.

#### Path-pattern entries

Concrete URLs match exactly. To authorize a family of URLs with a single entry —
useful for templated routes like `/customers/{id}` or runtime-generated ids —
register the URL with FastAPI/Express-style placeholders:

| Placeholder | Matches | Example |
|---|---|---|
| `{name}` | exactly one path segment (no `/`) | `https://api.example.com/customers/{id}` matches `…/customers/42` but **not** `…/customers/42/orders` |
| `{name:path}` | any subtree (including `/` separators) | `https://api.example.com/customers/{rest:path}` matches both `…/customers/42` and `…/customers/42/orders` |

The placeholder name (`id`, `rest`, …) is purely descriptive and does not affect
matching. Plain URLs without `{` characters keep exact-match semantics — no
behavior change for existing entries.

```sql
-- Register a templated route once instead of enumerating every concrete id
INSERT INTO trusted_endpoints (org_id, normalized_url, display_label, entry_type)
VALUES ('my-org', 'https://api.example.com/customers/{id}', 'Customers (by id)', 'endpoint');
```

`is_trusted_endpoint` and the snapshot tamper-check inside `evaluate_handoff`
both honor the same matching rules, so a claim against `…/customers/42` will
pass both gates when only the templated entry is registered.

## Public API

All public symbols are re-exported from the top-level `provably` namespace. See
Expand All @@ -414,7 +446,7 @@ from provably import (
HandoffPayload, HandoffClaim, HandoffProofAction, HandoffProofBundle,
BenchmarkRow, Outcome, VerificationMode,
# handoff transport
post_handoff, default_cluster_b_url,
post_handoff,
# handoff builders
build_handoff_payload, DEFAULT_HANDOFF_TASK,
claim_contract, default_instructions, field_descriptions,
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ src/provably/
__init__.py
client.py initialize_runtime
types.py HandoffPayload v2, HandoffClaim, etc.
transport.py post_handoff, default_cluster_b_url
transport.py post_handoff
evaluator.py evaluate_handoff, extract_indexed_from_query_record
eval_modes.py the four verification modes
json_utils.py canonical_json
Expand Down
6 changes: 3 additions & 3 deletions docs/handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ post_handoff(
There is no retry, no batching, no fallback. Failures bubble up as
`httpx.HTTPError` / `httpx.HTTPStatusError`.

`default_cluster_b_url()` is a small convenience that returns
`os.getenv("CLUSTER_B_URL", "http://localhost:8082")` with whitespace and
trailing-slash trimming. Use it where it helps; ignore it otherwise.
The `receiver_url` is supplied by the caller — the SDK does not read it from the
environment or assume any default. Configuration of where YOUR verifier lives
belongs in your application, not the SDK.

## Eval comparison modes

Expand Down
3 changes: 1 addition & 2 deletions src/provably/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from provably.handoff.guide import default_instructions, field_descriptions
from provably.handoff.outcomes import aggregate_outcome, outcome_from_trace
from provably.handoff.payload_builder import DEFAULT_HANDOFF_TASK, build_handoff_payload
from provably.handoff.transport import default_cluster_b_url, post_handoff
from provably.handoff.transport import post_handoff
from provably.handoff.types import (
BenchmarkRow,
HandoffClaim,
Expand Down Expand Up @@ -49,7 +49,6 @@
"check_claim_endpoints_are_trusted",
"claim_contract",
"configure_indexing",
"default_cluster_b_url",
"default_instructions",
"disable",
"enable",
Expand Down
18 changes: 9 additions & 9 deletions src/provably/handoff/transport.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
from __future__ import annotations

import os

import httpx

from provably.handoff.types import HandoffPayload
Expand All @@ -12,15 +10,21 @@


def post_handoff(
cluster_b_url: str,
receiver_url: str,
handoff_payload: HandoffPayload,
*,
headers: dict[str, str] | None = None,
timeout_s: float = 120.0,
) -> None:
base = (cluster_b_url or "").strip().rstrip("/")
"""POST a serialized ``HandoffPayload`` to ``{receiver_url}/handoffs/receive``.

The receiver is whatever service runs ``evaluate_handoff`` on the payload — typically
a separate verifier in a two-service deployment, but the SDK has no opinion on its
location: ``receiver_url`` is supplied by the caller, never read from the environment.
"""
base = (receiver_url or "").strip().rstrip("/")
if not base:
raise ValueError("cluster_b_url is empty — set CLUSTER_B_URL to post handoff")
raise ValueError("receiver_url is empty — pass the verifier's base URL to post_handoff")
url = f"{base}/handoffs/receive"
body = handoff_payload.model_dump(mode="json")
hdrs = {"Content-Type": "application/json", **(headers or {})}
Expand All @@ -31,7 +35,3 @@ def post_handoff(
except Exception as e:
_log.error("post_handoff_failed", url=url, error=str(e))
raise


def default_cluster_b_url() -> str:
return (os.getenv("CLUSTER_B_URL") or "http://localhost:8082").strip().rstrip("/")
89 changes: 86 additions & 3 deletions src/provably/trusted_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import re
from functools import lru_cache
from typing import TYPE_CHECKING
from urllib.parse import urlparse

Expand All @@ -12,6 +14,58 @@

_DDL_DONE = False

# ---------------------------------------------------------------------------
# Pattern matching
#
# A registered URL may contain FastAPI/Express-style path placeholders so a single
# entry can authorize a family of concrete URLs:
#
# {name} — matches one path segment (no '/'). E.g. /customers/{id} matches
# /customers/123 but NOT /customers/123/orders.
# {name:path} — matches any subtree, including '/' separators. E.g.
# /customers/{rest:path} matches both /customers/123 and
# /customers/123/orders.
#
# Plain URLs (no '{' character) keep exact-match semantics — no behavior change for
# existing entries.
# ---------------------------------------------------------------------------

_PLACEHOLDER_RE = re.compile(r"\{[^}/]+(?::path)?\}")


@lru_cache(maxsize=512)
def _compile_pattern(registered: str) -> re.Pattern[str] | None:
"""Compile a registered URL into a regex if it has placeholders, else return None.

Cache keeps regex compilation off the hot per-request path.
"""
if "{" not in registered:
return None
parts: list[str] = []
cursor = 0
has_placeholder = False
for match in _PLACEHOLDER_RE.finditer(registered):
parts.append(re.escape(registered[cursor : match.start()]))
is_path = ":path" in match.group(0)
parts.append(".+?" if is_path else "[^/]+?")
cursor = match.end()
has_placeholder = True
if not has_placeholder:
return None
parts.append(re.escape(registered[cursor:]))
try:
return re.compile(f"^{''.join(parts)}$")
except re.error:
return None


def _matches_registered(claim_url: str, registered: str) -> bool:
"""``True`` when ``claim_url`` exactly matches ``registered`` or matches its pattern."""
if claim_url == registered:
return True
pattern = _compile_pattern(registered)
return pattern is not None and pattern.match(claim_url) is not None


def normalize_url_for_trust(url: str) -> str:
"""Return the canonical form of ``url`` used for trust look-ups.
Expand Down Expand Up @@ -74,14 +128,21 @@ def ensure_trusted_endpoints_table(conn: psycopg2.extensions.connection) -> None


def is_trusted_endpoint(url: str, org_id: str, conn: psycopg2.extensions.connection) -> bool:
"""Return whether ``url`` is currently allowlisted for ``org_id``; normalizes URL before look-up."""
"""Return whether ``url`` is currently allowlisted for ``org_id``.

Two-phase lookup: exact match first (fast path, single indexed query), then a
pattern-match scan over only the rows containing ``{`` in their ``normalized_url``.
Plain URLs without placeholders never enter the slow path, so existing exact-match
registries see no perf regression.
"""
if not url or not org_id:
return False
norm = normalize_url_for_trust(url)
if not norm:
return False
_ensure_trusted_table(conn)
with conn.cursor() as cur:
# Fast path: exact match.
cur.execute(
"""
SELECT 1 FROM trusted_endpoints
Expand All @@ -90,7 +151,21 @@ def is_trusted_endpoint(url: str, org_id: str, conn: psycopg2.extensions.connect
""",
(org_id, norm),
)
return cur.fetchone() is not None
if cur.fetchone() is not None:
return True
# Slow path: pattern entries only.
cur.execute(
"""
SELECT normalized_url FROM trusted_endpoints
WHERE org_id = %s AND entry_type = 'endpoint' AND revoked_at IS NULL
AND normalized_url LIKE '%%{%%'
""",
(org_id,),
)
for (registered,) in cur.fetchall():
if _matches_registered(norm, str(registered or "")):
return True
return False


def list_trusted_endpoints(
Expand Down Expand Up @@ -208,7 +283,15 @@ def check_claim_endpoints_are_trusted(

registry = {n for url in hp.trusted_endpoint_registry if (n := normalize_url_for_trust(str(url)))}
if registry:
missing = list(dict.fromkeys(u for u in claim_urls if u not in registry))
pattern_entries = [r for r in registry if "{" in r]
missing: list[str] = []
for claim_url in claim_urls:
if claim_url in registry:
continue
if any(_matches_registered(claim_url, entry) for entry in pattern_entries):
continue
missing.append(claim_url)
missing = list(dict.fromkeys(missing))
if missing:
raise ValueError(f"handoff has endpoints missing from trusted snapshot: {', '.join(missing)}")

Expand Down
12 changes: 1 addition & 11 deletions tests/unit/test_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,10 @@

import pytest

from provably.handoff.transport import default_cluster_b_url, post_handoff
from provably.handoff.transport import post_handoff
from provably.handoff.types import HandoffPayload


def test_default_cluster_b_url_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CLUSTER_B_URL", "http://custom:9999/")
assert default_cluster_b_url() == "http://custom:9999"


def test_default_cluster_b_url_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CLUSTER_B_URL", raising=False)
assert default_cluster_b_url() == "http://localhost:8082"


def test_post_handoff_empty_url_raises() -> None:
with pytest.raises(ValueError, match="empty"):
post_handoff("", HandoffPayload())
Expand Down
Loading
Loading