getstack 0.9.0__tar.gz → 0.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {getstack-0.9.0 → getstack-0.11.0}/PKG-INFO +1 -1
  2. {getstack-0.9.0 → getstack-0.11.0}/pyproject.toml +1 -1
  3. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/__init__.py +5 -0
  4. getstack-0.11.0/src/getstack/_async/partner_tenants.py +117 -0
  5. getstack-0.11.0/src/getstack/partner_tenants.py +129 -0
  6. getstack-0.11.0/src/getstack/passport_header_verify.py +109 -0
  7. {getstack-0.9.0 → getstack-0.11.0}/.gitignore +0 -0
  8. {getstack-0.9.0 → getstack-0.11.0}/CLAUDE.md +0 -0
  9. {getstack-0.9.0 → getstack-0.11.0}/LICENSE +0 -0
  10. {getstack-0.9.0 → getstack-0.11.0}/README.md +0 -0
  11. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/__init__.py +0 -0
  12. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/agents.py +0 -0
  13. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/audit.py +0 -0
  14. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/credentials.py +0 -0
  15. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/dropoffs.py +0 -0
  16. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/identity.py +0 -0
  17. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/inbound_webhooks.py +0 -0
  18. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/intents.py +0 -0
  19. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/llm.py +0 -0
  20. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/missions.py +0 -0
  21. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/notifications.py +0 -0
  22. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/passport_session.py +0 -0
  23. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/passports.py +0 -0
  24. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/proxy.py +0 -0
  25. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/reviews.py +0 -0
  26. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/scan.py +0 -0
  27. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/security_events.py +0 -0
  28. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/services.py +0 -0
  29. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/skills.py +0 -0
  30. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/agent_auth.py +0 -0
  31. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/agents.py +0 -0
  32. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/audit.py +0 -0
  33. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/auth.py +0 -0
  34. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/browser_bootstrap.py +0 -0
  35. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/client.py +0 -0
  36. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/credentials.py +0 -0
  37. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/dropoffs.py +0 -0
  38. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/errors.py +0 -0
  39. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/evidence_packs.py +0 -0
  40. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/identity.py +0 -0
  41. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/inbound_webhooks.py +0 -0
  42. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/intents.py +0 -0
  43. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/llm.py +0 -0
  44. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/missions.py +0 -0
  45. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/notifications.py +0 -0
  46. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/passport_session.py +0 -0
  47. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/passports.py +0 -0
  48. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/proxy.py +0 -0
  49. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/py.typed +0 -0
  50. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/reviews.py +0 -0
  51. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/scan.py +0 -0
  52. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/security_events.py +0 -0
  53. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/services.py +0 -0
  54. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/skills.py +0 -0
  55. {getstack-0.9.0 → getstack-0.11.0}/src/getstack/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: getstack
3
- Version: 0.9.0
3
+ Version: 0.11.0
4
4
  Summary: Python SDK for STACK — trust infrastructure for AI agents
5
5
  Project-URL: Homepage, https://getstack.run
6
6
  Project-URL: Documentation, https://getstack.run/docs/sdk
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "getstack"
7
- version = "0.9.0"
7
+ version = "0.11.0"
8
8
  description = "Python SDK for STACK — trust infrastructure for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -53,6 +53,8 @@ from .security_events import SecurityEventService as SecurityEventSvc
53
53
  from .skills import SkillService
54
54
  from .identity import IdentityService
55
55
  from .inbound_webhooks import InboundWebhookService
56
+ from .partner_tenants import PartnerTenantsService
57
+ from .passport_header_verify import verify_passport_header
56
58
  from .intents import IntentsService
57
59
  from .missions import MissionsService
58
60
  from .evidence_packs import EvidencePacksService
@@ -226,6 +228,7 @@ class Stack:
226
228
  self.evidence_packs = EvidencePacksService(self._client)
227
229
  self.llm = LlmService(self._client)
228
230
  self.inbound_webhooks = InboundWebhookService(self._client)
231
+ self.partner_tenants = PartnerTenantsService(self._client)
229
232
 
230
233
  def issue_passport(
231
234
  self,
@@ -366,6 +369,7 @@ class AsyncStack:
366
369
  from ._async.missions import AsyncMissionsService
367
370
  from ._async.llm import AsyncLlmService
368
371
  from ._async.inbound_webhooks import AsyncInboundWebhookService
372
+ from ._async.partner_tenants import AsyncPartnerTenantsService
369
373
  from ._async.passport_session import AsyncPassportSession
370
374
 
371
375
  self.agents = AsyncAgentService(self._client)
@@ -385,6 +389,7 @@ class AsyncStack:
385
389
  self.missions = AsyncMissionsService(self._client)
386
390
  self.llm = AsyncLlmService(self._client)
387
391
  self.inbound_webhooks = AsyncInboundWebhookService(self._client)
392
+ self.partner_tenants = AsyncPartnerTenantsService(self._client)
388
393
  # Stash for issue_passport() — Python doesn't easily expose the
389
394
  # class binding without re-importing inside the method body.
390
395
  self._AsyncPassportSession = AsyncPassportSession
@@ -0,0 +1,117 @@
1
+ """Async mirror of PartnerTenantsService (Phase 5.6)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal, Optional
6
+
7
+ from ..client import AsyncHttpClient
8
+
9
+
10
+ Tier = Literal["free", "developer", "studio", "enterprise"]
11
+
12
+
13
+ class AsyncPartnerTenantsService:
14
+ def __init__(self, client: AsyncHttpClient):
15
+ self._client = client
16
+
17
+ async def create(
18
+ self,
19
+ external_customer_id: Optional[str] = None,
20
+ tier: Optional[Tier] = None,
21
+ ) -> dict[str, Any]:
22
+ body: dict[str, Any] = {}
23
+ if external_customer_id is not None:
24
+ body["external_customer_id"] = external_customer_id
25
+ if tier is not None:
26
+ body["tier"] = tier
27
+ return await self._client.post("/v1/partner/tenants", json=body)
28
+
29
+ async def list(self) -> list[dict[str, Any]]:
30
+ res = await self._client.get("/v1/partner/tenants")
31
+ return list(res.get("tenants", []))
32
+
33
+ async def get(self, tenant_id: str) -> dict[str, Any]:
34
+ return await self._client.get(f"/v1/partner/tenants/{tenant_id}")
35
+
36
+ async def revoke(self, tenant_id: str) -> dict[str, Any]:
37
+ return await self._client.delete(f"/v1/partner/tenants/{tenant_id}")
38
+
39
+ async def create_inbound_webhook(
40
+ self,
41
+ tenant_id: str,
42
+ provider: Literal["agentmail", "generic"],
43
+ agent_id: str,
44
+ forward_url: Optional[str] = None,
45
+ generic_config: Optional[dict[str, Any]] = None,
46
+ ) -> dict[str, Any]:
47
+ body: dict[str, Any] = {"provider": provider, "agent_id": agent_id}
48
+ if forward_url is not None:
49
+ body["forward_url"] = forward_url
50
+ if generic_config is not None:
51
+ body["generic_config"] = generic_config
52
+ return await self._client.post(
53
+ f"/v1/partner/tenants/{tenant_id}/inbound-webhooks",
54
+ json=body,
55
+ )
56
+
57
+ async def issue_passport(
58
+ self,
59
+ tenant_id: str,
60
+ agent_id: str,
61
+ ttl_seconds: Optional[int] = None,
62
+ services: Optional[list[dict[str, Any]]] = None,
63
+ named_intents: Optional[list[str]] = None,
64
+ accountability_mode: Optional[Literal["enforced", "logged", "standard"]] = None,
65
+ ) -> dict[str, Any]:
66
+ body: dict[str, Any] = {"agent_id": agent_id}
67
+ if ttl_seconds is not None:
68
+ body["ttl_seconds"] = ttl_seconds
69
+ if services is not None:
70
+ body["services"] = services
71
+ if named_intents is not None:
72
+ body["named_intents"] = named_intents
73
+ if accountability_mode is not None:
74
+ body["accountability_mode"] = accountability_mode
75
+ return await self._client.post(
76
+ f"/v1/partner/tenants/{tenant_id}/passports/issue",
77
+ json=body,
78
+ )
79
+
80
+ async def mint_outbound_passport(
81
+ self,
82
+ tenant_id: str,
83
+ agent_id: str,
84
+ scopes: Optional[list[str]] = None,
85
+ ttl_seconds: int = 900,
86
+ accountability_mode: Literal["enforced", "logged", "standard"] = "enforced",
87
+ named_intents: Optional[list[str]] = None,
88
+ ) -> dict[str, Any]:
89
+ """Phase 5.7 — convenience helper for the MIME-stamping pattern.
90
+
91
+ Async mirror of PartnerTenantsService.mint_outbound_passport.
92
+ """
93
+ services = [{"scopes": scopes}] if scopes else None
94
+ issued = await self.issue_passport(
95
+ tenant_id,
96
+ agent_id,
97
+ ttl_seconds=ttl_seconds,
98
+ accountability_mode=accountability_mode,
99
+ services=services,
100
+ named_intents=named_intents,
101
+ )
102
+ token = issued.get("token") or issued.get("passport")
103
+ passport_id = issued.get("passport_id") or issued.get("jti")
104
+ if not token or not passport_id:
105
+ raise RuntimeError("mint_outbound_passport: API response missing token or passport_id")
106
+ expires_at = issued.get("expires_at")
107
+ if not expires_at:
108
+ exp = issued.get("exp")
109
+ if isinstance(exp, (int, float)):
110
+ from datetime import datetime, timezone
111
+ expires_at = datetime.fromtimestamp(exp, tz=timezone.utc).isoformat()
112
+ return {
113
+ "token": token,
114
+ "passport_id": passport_id,
115
+ "expires_at": expires_at,
116
+ "header_value": token,
117
+ }
@@ -0,0 +1,129 @@
1
+ """Partner-tenancy surface (Phase 5.6).
2
+
3
+ Platform partners (AgentMail, Cofounder, etc.) with is_partner=True on
4
+ their operator row provision isolated child operators on behalf of
5
+ their customers via this service.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Literal, Optional
11
+
12
+ from .client import HttpClient
13
+
14
+
15
+ Tier = Literal["free", "developer", "studio", "enterprise"]
16
+
17
+
18
+ class PartnerTenantsService:
19
+ def __init__(self, client: HttpClient):
20
+ self._client = client
21
+
22
+ def create(
23
+ self,
24
+ external_customer_id: Optional[str] = None,
25
+ tier: Optional[Tier] = None,
26
+ ) -> dict[str, Any]:
27
+ body: dict[str, Any] = {}
28
+ if external_customer_id is not None:
29
+ body["external_customer_id"] = external_customer_id
30
+ if tier is not None:
31
+ body["tier"] = tier
32
+ return self._client.post("/v1/partner/tenants", json=body)
33
+
34
+ def list(self) -> list[dict[str, Any]]:
35
+ res = self._client.get("/v1/partner/tenants")
36
+ return list(res.get("tenants", []))
37
+
38
+ def get(self, tenant_id: str) -> dict[str, Any]:
39
+ return self._client.get(f"/v1/partner/tenants/{tenant_id}")
40
+
41
+ def revoke(self, tenant_id: str) -> dict[str, Any]:
42
+ return self._client.delete(f"/v1/partner/tenants/{tenant_id}")
43
+
44
+ def create_inbound_webhook(
45
+ self,
46
+ tenant_id: str,
47
+ provider: Literal["agentmail", "generic"],
48
+ agent_id: str,
49
+ forward_url: Optional[str] = None,
50
+ generic_config: Optional[dict[str, Any]] = None,
51
+ ) -> dict[str, Any]:
52
+ body: dict[str, Any] = {"provider": provider, "agent_id": agent_id}
53
+ if forward_url is not None:
54
+ body["forward_url"] = forward_url
55
+ if generic_config is not None:
56
+ body["generic_config"] = generic_config
57
+ return self._client.post(
58
+ f"/v1/partner/tenants/{tenant_id}/inbound-webhooks",
59
+ json=body,
60
+ )
61
+
62
+ def issue_passport(
63
+ self,
64
+ tenant_id: str,
65
+ agent_id: str,
66
+ ttl_seconds: Optional[int] = None,
67
+ services: Optional[list[dict[str, Any]]] = None,
68
+ named_intents: Optional[list[str]] = None,
69
+ accountability_mode: Optional[Literal["enforced", "logged", "standard"]] = None,
70
+ ) -> dict[str, Any]:
71
+ body: dict[str, Any] = {"agent_id": agent_id}
72
+ if ttl_seconds is not None:
73
+ body["ttl_seconds"] = ttl_seconds
74
+ if services is not None:
75
+ body["services"] = services
76
+ if named_intents is not None:
77
+ body["named_intents"] = named_intents
78
+ if accountability_mode is not None:
79
+ body["accountability_mode"] = accountability_mode
80
+ return self._client.post(
81
+ f"/v1/partner/tenants/{tenant_id}/passports/issue",
82
+ json=body,
83
+ )
84
+
85
+ def mint_outbound_passport(
86
+ self,
87
+ tenant_id: str,
88
+ agent_id: str,
89
+ scopes: Optional[list[str]] = None,
90
+ ttl_seconds: int = 900,
91
+ accountability_mode: Literal["enforced", "logged", "standard"] = "enforced",
92
+ named_intents: Optional[list[str]] = None,
93
+ ) -> dict[str, Any]:
94
+ """Phase 5.7 — convenience helper for the MIME-stamping pattern.
95
+
96
+ Mints a passport under the tenant's per-operator signing key
97
+ with a default-narrow scope, returning the token along with
98
+ the value ready for direct header injection (`X-Stack-Passport:
99
+ <token>`). The receiver verifies offline via verify_passport_header.
100
+
101
+ Defaults: 15-minute TTL, accountability_mode='enforced'.
102
+
103
+ Returns dict with keys: token, passport_id, expires_at, header_value.
104
+ """
105
+ services = [{"scopes": scopes}] if scopes else None
106
+ issued = self.issue_passport(
107
+ tenant_id,
108
+ agent_id,
109
+ ttl_seconds=ttl_seconds,
110
+ accountability_mode=accountability_mode,
111
+ services=services,
112
+ named_intents=named_intents,
113
+ )
114
+ token = issued.get("token") or issued.get("passport")
115
+ passport_id = issued.get("passport_id") or issued.get("jti")
116
+ if not token or not passport_id:
117
+ raise RuntimeError("mint_outbound_passport: API response missing token or passport_id")
118
+ expires_at = issued.get("expires_at")
119
+ if not expires_at:
120
+ exp = issued.get("exp")
121
+ if isinstance(exp, (int, float)):
122
+ from datetime import datetime, timezone
123
+ expires_at = datetime.fromtimestamp(exp, tz=timezone.utc).isoformat()
124
+ return {
125
+ "token": token,
126
+ "passport_id": passport_id,
127
+ "expires_at": expires_at,
128
+ "header_value": token,
129
+ }
@@ -0,0 +1,109 @@
1
+ """Partner-side helper for the X-Stack-Passport convention (Phase 5.7).
2
+
3
+ Pulls the passport token from request headers (or accepts the raw
4
+ string), looks up the issuing tenant's JWKS, and verifies offline.
5
+ No network at verify time after first JWKS fetch per tenant.
6
+
7
+ Usage:
8
+
9
+ from getstack import verify_passport_header
10
+
11
+ claims = verify_passport_header(
12
+ request.headers.get('x-stack-passport'),
13
+ base_url='https://api.getstack.run',
14
+ )
15
+ tenant_id = claims['stk']['operator_id']
16
+ agent_id = claims['stk']['agent_id']
17
+
18
+ Does NOT check revocation. For revocation-aware verification, layer
19
+ a short-window cache on POST /v1/passports/verify.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any, Optional, Union
25
+
26
+ import jwt
27
+ from jwt import PyJWKClient
28
+
29
+
30
+ DEFAULT_BASE_URL = "https://api.getstack.run"
31
+ DEFAULT_ISSUER = "getstack.run"
32
+ DEFAULT_AUDIENCE = "stack:passport"
33
+
34
+ # Cache PyJWKClient per JWKS URL so repeat verifies share the key cache.
35
+ _jwks_client_cache: dict[str, PyJWKClient] = {}
36
+
37
+
38
+ def _get_jwks_client(jwks_url: str) -> PyJWKClient:
39
+ client = _jwks_client_cache.get(jwks_url)
40
+ if client is None:
41
+ client = PyJWKClient(jwks_url)
42
+ _jwks_client_cache[jwks_url] = client
43
+ return client
44
+
45
+
46
+ def verify_passport_header(
47
+ header_value: Optional[Union[str, list[str]]],
48
+ *,
49
+ base_url: str = DEFAULT_BASE_URL,
50
+ issuer: str = DEFAULT_ISSUER,
51
+ audience: str = DEFAULT_AUDIENCE,
52
+ ) -> dict[str, Any]:
53
+ """Verify an X-Stack-Passport header value.
54
+
55
+ The header value is a STACK passport JWT signed under the issuing
56
+ tenant's per-operator signing key. This function decodes the JWT
57
+ (unverified) to read the sub claim (= tenant operator_id), resolves
58
+ the per-tenant JWKS URL, and verifies signature + issuer +
59
+ audience + expiry.
60
+
61
+ Raises ValueError on any failure — missing header, malformed JWT,
62
+ missing sub, signature failure, wrong issuer/audience, expired.
63
+ The caller should treat any raise as a 401 reject.
64
+
65
+ Returns the decoded claims dict on success.
66
+ """
67
+ if header_value is None:
68
+ raise ValueError("verify_passport_header: missing header value")
69
+ if isinstance(header_value, list):
70
+ if not header_value:
71
+ raise ValueError("verify_passport_header: header value is empty list")
72
+ token = header_value[0]
73
+ else:
74
+ token = header_value
75
+ if not isinstance(token, str) or not token:
76
+ raise ValueError("verify_passport_header: header value is empty")
77
+
78
+ # Decode unverified to read the tenant operator_id. The signature
79
+ # check that follows binds the sub to the JWKS so an attacker can't
80
+ # substitute a different tenant's sub.
81
+ try:
82
+ unverified = jwt.decode(token, options={"verify_signature": False})
83
+ except jwt.PyJWTError as err:
84
+ raise ValueError(f"verify_passport_header: malformed JWT ({err})") from err
85
+
86
+ tenant_id = unverified.get("sub")
87
+ if not tenant_id or not isinstance(tenant_id, str):
88
+ raise ValueError("verify_passport_header: token missing sub claim")
89
+
90
+ jwks_url = f"{base_url.rstrip('/')}/v1/operators/{tenant_id}/jwks.json"
91
+ jwk_client = _get_jwks_client(jwks_url)
92
+
93
+ try:
94
+ signing_key = jwk_client.get_signing_key_from_jwt(token)
95
+ except jwt.PyJWKClientError as err:
96
+ raise ValueError(f"verify_passport_header: jwks lookup failed ({err})") from err
97
+
98
+ try:
99
+ verified = jwt.decode(
100
+ token,
101
+ signing_key.key,
102
+ algorithms=["EdDSA"],
103
+ issuer=issuer,
104
+ audience=audience,
105
+ )
106
+ except jwt.InvalidTokenError as err:
107
+ raise ValueError(f"verify_passport_header: signature/claims invalid ({err})") from err
108
+
109
+ return verified
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes