Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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.2.0

- Added `provably.configure_indexing(enable_indexing: bool)`: one-call bootstrap (`initialize_runtime` + `init_interceptor` + `enable` / `disable`) for sender agents.
Expand Down
40 changes: 36 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,14 +160,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 @@ -258,11 +265,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 @@ -346,6 +353,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 @@ -364,7 +396,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 @@ -11,15 +9,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 @@ -29,7 +33,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