getstack 0.1.0__tar.gz → 0.2.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 (28) hide show
  1. getstack-0.2.0/.gitignore +61 -0
  2. getstack-0.2.0/CLAUDE.md +47 -0
  3. {getstack-0.1.0 → getstack-0.2.0}/PKG-INFO +1 -1
  4. {getstack-0.1.0 → getstack-0.2.0}/pyproject.toml +1 -1
  5. {getstack-0.1.0 → getstack-0.2.0}/src/getstack/__init__.py +26 -1
  6. getstack-0.2.0/src/getstack/audit.py +56 -0
  7. {getstack-0.1.0 → getstack-0.2.0}/src/getstack/client.py +4 -3
  8. getstack-0.2.0/src/getstack/credentials.py +44 -0
  9. getstack-0.2.0/src/getstack/identity.py +119 -0
  10. {getstack-0.1.0 → getstack-0.2.0}/src/getstack/notifications.py +10 -5
  11. {getstack-0.1.0 → getstack-0.2.0}/src/getstack/passports.py +44 -0
  12. getstack-0.2.0/src/getstack/proxy.py +109 -0
  13. getstack-0.2.0/src/getstack/scan.py +77 -0
  14. getstack-0.2.0/src/getstack/security_events.py +27 -0
  15. getstack-0.2.0/src/getstack/skills.py +199 -0
  16. {getstack-0.1.0 → getstack-0.2.0}/src/getstack/types.py +88 -0
  17. getstack-0.1.0/.gitignore +0 -38
  18. getstack-0.1.0/src/getstack/audit.py +0 -26
  19. getstack-0.1.0/src/getstack/credentials.py +0 -33
  20. {getstack-0.1.0 → getstack-0.2.0}/LICENSE +0 -0
  21. {getstack-0.1.0 → getstack-0.2.0}/README.md +0 -0
  22. {getstack-0.1.0 → getstack-0.2.0}/src/getstack/agents.py +0 -0
  23. {getstack-0.1.0 → getstack-0.2.0}/src/getstack/auth.py +0 -0
  24. {getstack-0.1.0 → getstack-0.2.0}/src/getstack/dropoffs.py +0 -0
  25. {getstack-0.1.0 → getstack-0.2.0}/src/getstack/errors.py +0 -0
  26. {getstack-0.1.0 → getstack-0.2.0}/src/getstack/py.typed +0 -0
  27. {getstack-0.1.0 → getstack-0.2.0}/src/getstack/reviews.py +0 -0
  28. {getstack-0.1.0 → getstack-0.2.0}/src/getstack/services.py +0 -0
@@ -0,0 +1,61 @@
1
+ node_modules/
2
+ dist/
3
+ .turbo/
4
+ *.tsbuildinfo
5
+ __pycache__/
6
+ *.pyc
7
+
8
+ # Environment / secrets
9
+ .env
10
+ .env.*
11
+ *.env
12
+ .npmrc
13
+ scripts/.env.cto
14
+
15
+ # Strategy docs (not sensitive, but private)
16
+ stack-claude-code-spec-v3.md
17
+ stack-gtm-distribution.md
18
+ stack-platform-spec-v2.md
19
+
20
+ # IDE
21
+ .vscode/settings.json
22
+ .idea/
23
+
24
+ # OS
25
+ .DS_Store
26
+ Thumbs.db
27
+
28
+ # Next.js
29
+ .next/
30
+ out/
31
+
32
+ # MCP publisher
33
+ mcp-publisher.exe
34
+ .mcpregistry_*
35
+
36
+ # Fly.io deployment config (contains app names, regions)
37
+ **/fly.toml
38
+ !apps/*/fly.toml
39
+ !**/fly.toml.example
40
+
41
+ # Logs
42
+ *.log
43
+ npm-debug.log*
44
+
45
+ # Claude Code edit audit log (per-machine paper trail of hook-logged mutations)
46
+ .claude/audit/
47
+
48
+ # Claude Code per-machine state (background scheduler)
49
+ .claude/scheduled_tasks.lock
50
+
51
+ # OpenAPI spec cache (regenerated by scripts/verify-intents-openapi.ts)
52
+ scripts/.openapi-cache/
53
+
54
+ # Local dev screenshots
55
+ *-stripe.png
56
+
57
+ # YC application session exports (founder voice + redacted creds — never commit)
58
+ docs/yc-q18/
59
+ docs/yc-application-session-preface.md
60
+ *-YC.md
61
+ *-yc.md
@@ -0,0 +1,47 @@
1
+ # getstack (Python SDK)
2
+
3
+ Published to PyPI as `getstack`. Not a workspace member of the main monorepo
4
+ (Python has its own tooling via `pyproject.toml` + its own tests).
5
+
6
+ ## Purpose
7
+
8
+ - Python ergonomic equivalent of `@getstackrun/sdk`
9
+ - `with stack.passports.mission(...) as m:` context manager that handles
10
+ checkpoints + checkout automatically
11
+ - Same RESTful coverage: agents, passports, services, credentials, drop-offs,
12
+ skills, identity, audit, notifications, security-events, proxy
13
+
14
+ ## Where to look
15
+
16
+ - Package metadata: `sdk-python/pyproject.toml`
17
+ - Module entry: `sdk-python/src/getstack/`
18
+ - Tests: `sdk-python/tests/`
19
+ - User-facing quickstart + examples: `sdk-python/README.md`
20
+ - License: `sdk-python/LICENSE`
21
+
22
+ ## Key divergence from JS SDK
23
+
24
+ The Python SDK has a **mission context manager** the JS SDK doesn't have:
25
+
26
+ ```python
27
+ with stack.passports.mission(
28
+ agent_id=agent.id,
29
+ intent="Process invoices from Slack",
30
+ services=["slack", "stripe"],
31
+ checkpoint_interval="5m",
32
+ ) as mission:
33
+ mission.log("slack", "read_channel", "#invoices")
34
+ mission.log("stripe", "create_invoice")
35
+ # Checkpoints submitted automatically on schedule
36
+ # Checkout submitted automatically when the block exits
37
+ ```
38
+
39
+ When the JS SDK adds an equivalent, align the shape (same keyword args,
40
+ same method names) for cross-language familiarity.
41
+
42
+ ## Gotchas
43
+
44
+ - **Not installed by `npm install` at monorepo root.** Python devs run `pip install -e sdk-python/` or `uv pip install -e sdk-python/`. The JS monorepo lockfile does not resolve Python deps.
45
+ - **Published to PyPI separately.** There's no coordinated release between the JS + Python SDKs; they can (and sometimes do) drift in version numbers.
46
+ - **Same authentication posture.** API keys, session JWTs, and the same `sk_live_*` prefix convention. Token type detection works identically to JS.
47
+ - **Offline passport verification is also available** (mirrors `verify_passport_offline` from the JS SDK) — but confirm the exact function name in `sdk-python/src/getstack/` before citing.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: getstack
3
- Version: 0.1.0
3
+ Version: 0.2.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.1.0"
7
+ version = "0.2.0"
8
8
  description = "Python SDK for STACK — trust infrastructure for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -43,6 +43,11 @@ from .dropoffs import DropoffService
43
43
  from .reviews import ReviewService
44
44
  from .notifications import NotificationService
45
45
  from .audit import AuditService
46
+ from .proxy import ProxyService as ProxySvc, ProxyResponse
47
+ from .scan import ScanService
48
+ from .security_events import SecurityEventService as SecurityEventSvc
49
+ from .skills import SkillService
50
+ from .identity import IdentityService
46
51
  from .types import (
47
52
  Agent,
48
53
  Passport,
@@ -56,6 +61,13 @@ from .types import (
56
61
  Credential,
57
62
  NotificationChannel,
58
63
  ToolCall,
64
+ Skill,
65
+ SkillInvocation,
66
+ SkillRequest,
67
+ IdentityProvider,
68
+ IdentityClaim,
69
+ IdentitySettings,
70
+ VerificationSession,
59
71
  )
60
72
  from .errors import (
61
73
  StackError,
@@ -66,7 +78,7 @@ from .errors import (
66
78
  PassportBlockedError,
67
79
  )
68
80
 
69
- __version__ = "0.1.0"
81
+ __version__ = "0.2.0"
70
82
 
71
83
  __all__ = [
72
84
  "Stack",
@@ -86,6 +98,14 @@ __all__ = [
86
98
  "NotificationChannel",
87
99
  "Mission",
88
100
  "ToolCall",
101
+ "ProxyResponse",
102
+ "Skill",
103
+ "SkillInvocation",
104
+ "SkillRequest",
105
+ "IdentityProvider",
106
+ "IdentityClaim",
107
+ "IdentitySettings",
108
+ "VerificationSession",
89
109
  # Errors
90
110
  "StackError",
91
111
  "NotFoundError",
@@ -129,6 +149,11 @@ class Stack:
129
149
  self.reviews = ReviewService(self._client)
130
150
  self.notifications = NotificationService(self._client)
131
151
  self.audit = AuditService(self._client)
152
+ self.proxy = ProxySvc(self._client)
153
+ self.scan = ScanService(self._client)
154
+ self.security_events = SecurityEventSvc(self._client)
155
+ self.skills = SkillService(self._client)
156
+ self.identity = IdentityService(self._client)
132
157
 
133
158
  @classmethod
134
159
  def from_session(cls, session_token: str, **kwargs) -> Stack:
@@ -0,0 +1,56 @@
1
+ """Audit log access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .client import HttpClient
8
+
9
+
10
+ class AuditService:
11
+ def __init__(self, client: HttpClient):
12
+ self._client = client
13
+
14
+ def list(
15
+ self,
16
+ limit: int = 20,
17
+ since: int | None = None,
18
+ agent_id: str | None = None,
19
+ passport_jti: str | None = None,
20
+ ) -> dict[str, Any]:
21
+ """Tail recent audit entries (newest-first).
22
+
23
+ Returns ``{"entries": [...], "max_timestamp": int | None}``.
24
+ Pass ``since`` (epoch ms) to fetch only entries newer than the
25
+ last seen timestamp — useful for incremental polling. Pass
26
+ ``agent_id`` or ``passport_jti`` to narrow to a single agent
27
+ or single passport.
28
+ """
29
+ params: dict[str, str] = {"limit": str(limit)}
30
+ if since is not None:
31
+ params["since"] = str(since)
32
+ if agent_id is not None:
33
+ params["agent_id"] = agent_id
34
+ if passport_jti is not None:
35
+ params["passport_jti"] = passport_jti
36
+ return self._client.get("/v1/audit", params=params)
37
+
38
+ def chain_head(self) -> dict[str, Any]:
39
+ """Current chain head — latest entry hash + entry count."""
40
+ return self._client.get("/v1/audit/chain-head")
41
+
42
+ def verify_chain(
43
+ self,
44
+ from_date: str | None = None,
45
+ to_date: str | None = None,
46
+ limit: int | None = None,
47
+ ) -> dict[str, Any]:
48
+ """Walk the per-operator chain and report tamper status."""
49
+ params: dict[str, str] = {}
50
+ if from_date is not None:
51
+ params["from"] = from_date
52
+ if to_date is not None:
53
+ params["to"] = to_date
54
+ if limit is not None:
55
+ params["limit"] = str(limit)
56
+ return self._client.get("/v1/audit/verify-chain", params=params)
@@ -20,10 +20,11 @@ class HttpClient:
20
20
  self.base_url = base_url.rstrip("/")
21
21
  self._client = httpx.Client(timeout=timeout)
22
22
 
23
- def request(self, method: str, path: str, json: Any = None, params: dict[str, Any] | None = None) -> Any:
23
+ def request(self, method: str, path: str, json: Any = None, params: dict[str, Any] | None = None, extra_headers: dict[str, str] | None = None) -> Any:
24
24
  headers = {
25
25
  "Content-Type": "application/json",
26
26
  **self.auth.get_headers(),
27
+ **(extra_headers or {}),
27
28
  }
28
29
 
29
30
  url = f"{self.base_url}{path}"
@@ -41,8 +42,8 @@ class HttpClient:
41
42
 
42
43
  return resp.json()
43
44
 
44
- def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
45
- return self.request("GET", path, params=params)
45
+ def get(self, path: str, params: dict[str, Any] | None = None, extra_headers: dict[str, str] | None = None) -> Any:
46
+ return self.request("GET", path, params=params, extra_headers=extra_headers)
46
47
 
47
48
  def post(self, path: str, json: Any = None) -> Any:
48
49
  return self.request("POST", path, json=json)
@@ -0,0 +1,44 @@
1
+ """Credential retrieval."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from urllib.parse import quote
7
+
8
+ from .client import HttpClient
9
+ from .types import Credential
10
+
11
+
12
+ class CredentialService:
13
+ def __init__(self, client: HttpClient):
14
+ self._client = client
15
+
16
+ def get(self, provider: str, passport_token: str | None = None) -> Credential:
17
+ """Retrieve credential for a provider. Optionally pass passport token for security signal tracking."""
18
+ extra_headers = {}
19
+ if passport_token:
20
+ extra_headers["X-Passport-Token"] = passport_token
21
+ data = self._client.get(
22
+ f"/v1/credentials/{quote(provider)}",
23
+ extra_headers=extra_headers if extra_headers else None,
24
+ )
25
+ return Credential(
26
+ provider=data["provider"],
27
+ credential=data.get("credential"),
28
+ credentials=data.get("credentials"),
29
+ )
30
+
31
+ def get_by_connection(self, connection_id: str, passport_token: str | None = None) -> Credential:
32
+ """Retrieve credential by connection ID."""
33
+ extra_headers = {}
34
+ if passport_token:
35
+ extra_headers["X-Passport-Token"] = passport_token
36
+ data = self._client.get(
37
+ f"/v1/credentials/by-connection/{connection_id}",
38
+ extra_headers=extra_headers if extra_headers else None,
39
+ )
40
+ return Credential(
41
+ provider=data["provider"],
42
+ credential=data.get("credential"),
43
+ credentials=data.get("credentials"),
44
+ )
@@ -0,0 +1,119 @@
1
+ """Identity verification — providers, claims, and verification flow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .client import HttpClient
8
+ from .types import IdentityProvider, IdentityClaim, IdentitySettings, VerificationSession
9
+
10
+
11
+ def _to_provider(data: dict[str, Any]) -> IdentityProvider:
12
+ return IdentityProvider(
13
+ key=data["key"],
14
+ name=data["name"],
15
+ layers=data.get("layers", []),
16
+ assurance=data.get("assurance", ""),
17
+ description=data.get("description", ""),
18
+ )
19
+
20
+
21
+ def _to_claim(data: dict[str, Any]) -> IdentityClaim:
22
+ return IdentityClaim(
23
+ id=data["id"],
24
+ operator_id=data["operator_id"],
25
+ provider_key=data["provider_key"],
26
+ layer=data["layer"],
27
+ claim_type=data["claim_type"],
28
+ claim_ref=data["claim_ref"],
29
+ assurance_level=data["assurance_level"],
30
+ verified_at=data["verified_at"],
31
+ expires_at=data.get("expires_at"),
32
+ )
33
+
34
+
35
+ class IdentityService:
36
+ def __init__(self, client: HttpClient):
37
+ self._client = client
38
+
39
+ def get_settings(self) -> IdentitySettings:
40
+ data = self._client.get("/v1/operators/me/identity-settings")
41
+ return IdentitySettings(
42
+ identity_claim_ttl=data["identity_claim_ttl"],
43
+ identity_claim_inheritance=data["identity_claim_inheritance"],
44
+ identity_auto_revoke=data["identity_auto_revoke"],
45
+ )
46
+
47
+ def update_settings(
48
+ self,
49
+ *,
50
+ identity_claim_ttl: str | None = None,
51
+ identity_claim_inheritance: str | None = None,
52
+ identity_auto_revoke: bool | None = None,
53
+ ) -> IdentitySettings:
54
+ body: dict[str, Any] = {}
55
+ if identity_claim_ttl is not None:
56
+ body["identity_claim_ttl"] = identity_claim_ttl
57
+ if identity_claim_inheritance is not None:
58
+ body["identity_claim_inheritance"] = identity_claim_inheritance
59
+ if identity_auto_revoke is not None:
60
+ body["identity_auto_revoke"] = identity_auto_revoke
61
+ data = self._client.patch("/v1/operators/me/identity-settings", json=body)
62
+ return IdentitySettings(
63
+ identity_claim_ttl=data["identity_claim_ttl"],
64
+ identity_claim_inheritance=data["identity_claim_inheritance"],
65
+ identity_auto_revoke=data["identity_auto_revoke"],
66
+ )
67
+
68
+ def list_providers(self) -> list[IdentityProvider]:
69
+ data = self._client.get("/v1/identity/providers")
70
+ return [_to_provider(p) for p in data]
71
+
72
+ def initiate_verification(
73
+ self,
74
+ provider_key: str,
75
+ *,
76
+ requested_claims: list[str] | None = None,
77
+ return_url: str | None = None,
78
+ ) -> VerificationSession:
79
+ body: dict[str, Any] = {"provider_key": provider_key}
80
+ if requested_claims:
81
+ body["requested_claims"] = requested_claims
82
+ if return_url:
83
+ body["return_url"] = return_url
84
+ data = self._client.post("/v1/identity/verify/initiate", json=body)
85
+ return VerificationSession(
86
+ session_ref=data["session_ref"],
87
+ authorization_url=data.get("authorization_url"),
88
+ status=data.get("status", "pending"),
89
+ )
90
+
91
+ def complete_verification(
92
+ self,
93
+ provider_key: str,
94
+ session_ref: str,
95
+ ) -> dict[str, Any]:
96
+ return self._client.post(
97
+ "/v1/identity/verify/complete",
98
+ json={"provider_key": provider_key, "session_ref": session_ref},
99
+ )
100
+
101
+ def grant_delegation(self, delegated_scopes: list[str]) -> IdentityClaim:
102
+ data = self._client.post(
103
+ "/v1/identity/delegate",
104
+ json={"delegated_scopes": delegated_scopes},
105
+ )
106
+ return _to_claim(data)
107
+
108
+ def list_claims(self) -> list[IdentityClaim]:
109
+ data = self._client.get("/v1/identity/claims")
110
+ return [_to_claim(c) for c in data]
111
+
112
+ def revoke_claim(self, claim_id: str) -> dict[str, Any]:
113
+ return self._client.delete(f"/v1/identity/claims/{claim_id}")
114
+
115
+ def get_service_requirements(self, service_id: str) -> dict[str, Any]:
116
+ return self._client.get(f"/v1/services/{service_id}/requirements")
117
+
118
+ def set_service_requirement(self, service_id: str, **kwargs: Any) -> dict[str, Any]:
119
+ return self._client.post(f"/v1/services/{service_id}/requirements", json=kwargs)
@@ -44,11 +44,16 @@ class NotificationService:
44
44
  body["events"] = events
45
45
  return _to_channel(self._client.post("/v1/notifications/channels", json=body))
46
46
 
47
- def verify(self, channel_id: str, code: str | None = None) -> dict[str, Any]:
48
- body: dict[str, Any] = {}
49
- if code:
50
- body["code"] = code
51
- return self._client.post(f"/v1/notifications/channels/{channel_id}/verify", json=body)
47
+ def send_verification_code(self, channel_id: str) -> dict[str, Any]:
48
+ """Send a verification code to the channel."""
49
+ return self._client.post(f"/v1/notifications/channels/{channel_id}/send-code")
50
+
51
+ def verify(self, channel_id: str, code: str) -> dict[str, Any]:
52
+ """Submit a verification code."""
53
+ return self._client.post(
54
+ f"/v1/notifications/channels/{channel_id}/verify",
55
+ json={"code": code},
56
+ )
52
57
 
53
58
  def test(self, channel_id: str) -> dict[str, Any]:
54
59
  return self._client.post(f"/v1/notifications/channels/{channel_id}/test")
@@ -9,6 +9,7 @@ from contextlib import contextmanager
9
9
  from typing import Any, Generator
10
10
 
11
11
  from .client import HttpClient
12
+ from .proxy import ProxyService, ProxyResponse
12
13
  from .types import Passport, CheckpointResult, CheckoutResult, PassportReport, ToolCall
13
14
 
14
15
 
@@ -85,6 +86,49 @@ class Mission:
85
86
  """Record that delegation occurred to a child agent."""
86
87
  self._delegated_to.append(child_agent_id)
87
88
 
89
+ @property
90
+ def token(self) -> str | None:
91
+ """Current passport token, for passing to credential retrieval."""
92
+ return self.passport.token if self.passport else None
93
+
94
+ def proxy(
95
+ self,
96
+ service: str,
97
+ url: str,
98
+ method: str = "GET",
99
+ *,
100
+ headers: dict[str, str] | None = None,
101
+ body: Any = None,
102
+ query: dict[str, str] | None = None,
103
+ _proxy_service: ProxyService | None = None,
104
+ ) -> ProxyResponse:
105
+ """Send a request through STACK's credential proxy using this mission's passport.
106
+
107
+ Requires passing the proxy service from the Stack client, or set it via
108
+ ``mission._proxy = stack.proxy`` before calling.
109
+
110
+ Args:
111
+ service: Provider slug (e.g. "openai", "slack").
112
+ url: Full target URL.
113
+ method: HTTP method.
114
+ headers: Extra headers (auth headers will be stripped).
115
+ body: Request body.
116
+ query: Query parameters.
117
+ _proxy_service: ProxyService instance (falls back to self._proxy).
118
+ """
119
+ ps = _proxy_service or getattr(self, "_proxy", None)
120
+ if ps is None:
121
+ raise RuntimeError(
122
+ "No proxy service available. Set mission._proxy = stack.proxy before calling proxy()."
123
+ )
124
+ resp = ps.request(
125
+ service, url, method,
126
+ headers=headers, body=body, query=query,
127
+ passport_token=self.token,
128
+ )
129
+ self.log(service, method, url)
130
+ return resp
131
+
88
132
  def _enter(self) -> None:
89
133
  """Issue passport and start checkpoint timer."""
90
134
  self.passport = self._service.issue(
@@ -0,0 +1,109 @@
1
+ """Credential proxy — send requests through STACK without seeing secrets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .client import HttpClient
8
+
9
+
10
+ class ProxyResponse:
11
+ """Response from a proxied request."""
12
+
13
+ __slots__ = ("status", "headers", "body")
14
+
15
+ def __init__(self, status: int, headers: dict[str, str], body: Any):
16
+ self.status = status
17
+ self.headers = headers
18
+ self.body = body
19
+
20
+ def ok(self) -> bool:
21
+ return 200 <= self.status < 400
22
+
23
+ def json(self) -> Any:
24
+ return self.body
25
+
26
+ def __repr__(self) -> str:
27
+ return f"ProxyResponse(status={self.status})"
28
+
29
+
30
+ class ProxyService:
31
+ def __init__(self, client: HttpClient):
32
+ self._client = client
33
+
34
+ def request(
35
+ self,
36
+ service: str,
37
+ url: str,
38
+ method: str = "GET",
39
+ *,
40
+ headers: dict[str, str] | None = None,
41
+ body: Any | None = None,
42
+ query: dict[str, str] | None = None,
43
+ passport_token: str | None = None,
44
+ ) -> ProxyResponse:
45
+ """Send a request through STACK's credential proxy.
46
+
47
+ Args:
48
+ service: Provider slug (e.g. "openai", "slack").
49
+ url: Full target URL.
50
+ method: HTTP method.
51
+ headers: Extra headers (auth headers will be stripped).
52
+ body: Request body.
53
+ query: Query parameters.
54
+ passport_token: Passport JWT (required).
55
+ """
56
+ extra_headers = {}
57
+ if passport_token:
58
+ extra_headers["X-Passport-Token"] = passport_token
59
+
60
+ payload: dict[str, Any] = {
61
+ "service": service,
62
+ "url": url,
63
+ "method": method,
64
+ }
65
+ if headers:
66
+ payload["headers"] = headers
67
+ if body is not None:
68
+ payload["body"] = body
69
+ if query:
70
+ payload["query"] = query
71
+
72
+ data = self._client.request(
73
+ "POST",
74
+ "/v1/proxy",
75
+ json=payload,
76
+ extra_headers=extra_headers if extra_headers else None,
77
+ )
78
+ return ProxyResponse(
79
+ status=data.get("status", 0),
80
+ headers=data.get("headers", {}),
81
+ body=data.get("body"),
82
+ )
83
+
84
+ def get(self, service: str, url: str, **kwargs) -> ProxyResponse:
85
+ """GET through proxy."""
86
+ return self.request(service, url, "GET", **kwargs)
87
+
88
+ def post(self, service: str, url: str, **kwargs) -> ProxyResponse:
89
+ """POST through proxy."""
90
+ return self.request(service, url, "POST", **kwargs)
91
+
92
+ def put(self, service: str, url: str, **kwargs) -> ProxyResponse:
93
+ """PUT through proxy."""
94
+ return self.request(service, url, "PUT", **kwargs)
95
+
96
+ def patch(self, service: str, url: str, **kwargs) -> ProxyResponse:
97
+ """PATCH through proxy."""
98
+ return self.request(service, url, "PATCH", **kwargs)
99
+
100
+ def delete(self, service: str, url: str, **kwargs) -> ProxyResponse:
101
+ """DELETE through proxy."""
102
+ return self.request(service, url, "DELETE", **kwargs)
103
+
104
+ def usage(self) -> dict:
105
+ """Get monthly proxy usage stats.
106
+
107
+ Returns dict with: tier, limit, used, remaining, period.
108
+ """
109
+ return self._client.request("GET", "/v1/proxy/usage")
@@ -0,0 +1,77 @@
1
+ """Content-scanning service — `/v1/scan` (L1+L2 prompt-injection detector).
2
+
3
+ Scan retrieved content (emails, documents, web pages, API responses)
4
+ for prompt-injection markers BEFORE feeding it into your LLM. Reuses
5
+ the same detector chain that runs on `/v1/proxy` and
6
+ `/v1/skills/:id/invoke` — L1 regex catalog + L2 encoding-aware
7
+ normalization.
8
+
9
+ Example::
10
+
11
+ result = stack.scan.scan(
12
+ content=email_body,
13
+ context="email",
14
+ source=sender_address,
15
+ )
16
+ if result["verdict"] == "critical":
17
+ raise RuntimeError(f"Indirect injection caught: {result['match']['pattern_id']}")
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ from .client import HttpClient
25
+
26
+
27
+ class ScanService:
28
+ def __init__(self, client: HttpClient):
29
+ self._client = client
30
+
31
+ def scan(
32
+ self,
33
+ content: str | dict[str, Any],
34
+ *,
35
+ context: str | None = None,
36
+ source: str | None = None,
37
+ passport_token: str | None = None,
38
+ ) -> dict[str, Any]:
39
+ """Scan retrieved content for prompt-injection markers.
40
+
41
+ Args:
42
+ content: The content to scan. Strings are scanned directly;
43
+ dicts are walked one level deep, same as proxy bodies.
44
+ context: Optional context tag — one of "email", "document",
45
+ "webpage", "chat_log", "api_response", "calendar", "other".
46
+ source: Optional source identifier (URL / sender / filename).
47
+ passport_token: Optional passport JWT for attribution to a
48
+ specific agent context.
49
+
50
+ Returns:
51
+ Dict with keys: verdict ("clean" | "suspicious" | "critical"),
52
+ scan_id, duration_ms, match (or None).
53
+ """
54
+ payload: dict[str, Any] = {"content": content}
55
+ if context is not None:
56
+ payload["context"] = context
57
+ if source is not None:
58
+ payload["source"] = source
59
+
60
+ extra_headers = None
61
+ if passport_token:
62
+ extra_headers = {"X-Passport-Token": passport_token}
63
+
64
+ return self._client.request(
65
+ "POST",
66
+ "/v1/scan",
67
+ json=payload,
68
+ extra_headers=extra_headers,
69
+ )
70
+
71
+ def usage(self) -> dict[str, Any]:
72
+ """Get monthly scan usage stats.
73
+
74
+ Returns:
75
+ Dict with keys: tier, limit, used, remaining, period.
76
+ """
77
+ return self._client.request("GET", "/v1/scan/usage")
@@ -0,0 +1,27 @@
1
+ """Security event management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .client import HttpClient
8
+
9
+
10
+ class SecurityEventService:
11
+ def __init__(self, client: HttpClient):
12
+ self._client = client
13
+
14
+ def list(self, agent_id: str | None = None, page: int = 1, limit: int = 50) -> dict[str, Any]:
15
+ """List unresolved security events."""
16
+ params: dict[str, str] = {"page": str(page), "limit": str(limit)}
17
+ if agent_id:
18
+ params["agent_id"] = agent_id
19
+ return self._client.get("/v1/security-events", params=params)
20
+
21
+ def get(self, event_id: str) -> dict[str, Any]:
22
+ """Get a specific security event."""
23
+ return self._client.get(f"/v1/security-events/{event_id}")
24
+
25
+ def resolve(self, event_id: str) -> dict[str, Any]:
26
+ """Mark a security event as resolved."""
27
+ return self._client.post(f"/v1/security-events/{event_id}/resolve")
@@ -0,0 +1,199 @@
1
+ """Skills marketplace — publish, invoke, browse, and manage skills."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ from .client import HttpClient
9
+ from .types import Skill, SkillInvocation, SkillRequest
10
+
11
+
12
+ def _to_skill(data: dict[str, Any]) -> Skill:
13
+ return Skill(
14
+ id=data["id"],
15
+ operator_id=data["operator_id"],
16
+ name=data["name"],
17
+ description=data["description"],
18
+ version=data.get("version", "1.0.0"),
19
+ tags=data.get("tags", []),
20
+ trust_level_required=data.get("trust_level_required", "L0"),
21
+ status=data.get("status", "active"),
22
+ execution_mode=data.get("execution_mode", "open"),
23
+ price_per_invocation=data.get("price_per_invocation", 0),
24
+ invocation_count=data.get("invocation_count", 0),
25
+ created_at=data["created_at"],
26
+ updated_at=data["updated_at"],
27
+ )
28
+
29
+
30
+ def _to_invocation(data: dict[str, Any]) -> SkillInvocation:
31
+ return SkillInvocation(
32
+ id=data["id"],
33
+ skill_id=data["skill_id"],
34
+ status=data["status"],
35
+ output=data.get("output"),
36
+ error=data.get("error"),
37
+ expires_at=data.get("expires_at", ""),
38
+ created_at=data.get("created_at", ""),
39
+ started_at=data.get("started_at"),
40
+ completed_at=data.get("completed_at"),
41
+ llm_tokens_input=data.get("llm_tokens_input"),
42
+ llm_tokens_output=data.get("llm_tokens_output"),
43
+ execution_duration_ms=data.get("execution_duration_ms"),
44
+ )
45
+
46
+
47
+ def _to_request(data: dict[str, Any]) -> SkillRequest:
48
+ return SkillRequest(
49
+ id=data["id"],
50
+ operator_id=data["operator_id"],
51
+ description=data["description"],
52
+ desired_input_schema=data.get("desired_input_schema"),
53
+ desired_output_schema=data.get("desired_output_schema"),
54
+ max_price_cents=data.get("max_price_cents"),
55
+ tags=data.get("tags", []),
56
+ status=data.get("status", "open"),
57
+ created_at=data["created_at"],
58
+ )
59
+
60
+
61
+ class SkillService:
62
+ def __init__(self, client: HttpClient):
63
+ self._client = client
64
+
65
+ # ─── Publishing ──────────────────────────────────────────────────
66
+
67
+ def publish(self, **kwargs: Any) -> Skill:
68
+ """Publish a new skill to the marketplace."""
69
+ return _to_skill(self._client.post("/v1/skills", json=kwargs))
70
+
71
+ def get(self, skill_id: str) -> Skill:
72
+ return _to_skill(self._client.get(f"/v1/skills/{skill_id}"))
73
+
74
+ def browse(
75
+ self,
76
+ *,
77
+ query: str | None = None,
78
+ tags: str | None = None,
79
+ trust_level: str | None = None,
80
+ status: str | None = None,
81
+ limit: int | None = None,
82
+ offset: int | None = None,
83
+ ) -> list[Skill]:
84
+ params: dict[str, str] = {}
85
+ if query:
86
+ params["query"] = query
87
+ if tags:
88
+ params["tags"] = tags
89
+ if trust_level:
90
+ params["trust_level"] = trust_level
91
+ if status:
92
+ params["status"] = status
93
+ if limit is not None:
94
+ params["limit"] = str(limit)
95
+ if offset is not None:
96
+ params["offset"] = str(offset)
97
+ data = self._client.get("/v1/skills", params=params)
98
+ return [_to_skill(s) for s in data]
99
+
100
+ def list_owned(self) -> list[Skill]:
101
+ data = self._client.get("/v1/skills/mine")
102
+ return [_to_skill(s) for s in data]
103
+
104
+ def update(self, skill_id: str, **kwargs: Any) -> Skill:
105
+ return _to_skill(self._client.patch(f"/v1/skills/{skill_id}", json=kwargs))
106
+
107
+ def suspend(self, skill_id: str) -> Skill:
108
+ return _to_skill(self._client.post(f"/v1/skills/{skill_id}/suspend"))
109
+
110
+ def activate(self, skill_id: str) -> Skill:
111
+ return _to_skill(self._client.post(f"/v1/skills/{skill_id}/activate"))
112
+
113
+ def check_trust(self, skill_id: str) -> dict[str, Any]:
114
+ # Trust claims are resolved server-side from the caller's DB-stored
115
+ # identity claims; callers no longer pass them in.
116
+ return self._client.post(f"/v1/skills/{skill_id}/check-trust", json={})
117
+
118
+ # ─── Invocations ─────────────────────────────────────────────────
119
+
120
+ def invoke(
121
+ self,
122
+ skill_id: str,
123
+ *,
124
+ agent_id: str,
125
+ input: dict[str, Any],
126
+ passport_id: str | None = None,
127
+ ) -> SkillInvocation:
128
+ body: dict[str, Any] = {"agent_id": agent_id, "input": input}
129
+ if passport_id:
130
+ body["passport_id"] = passport_id
131
+ return _to_invocation(self._client.post(f"/v1/skills/{skill_id}/invoke", json=body))
132
+
133
+ def get_invocation(self, invocation_id: str) -> SkillInvocation:
134
+ return _to_invocation(self._client.get(f"/v1/skills/invocations/{invocation_id}"))
135
+
136
+ def list_invocations(self) -> list[SkillInvocation]:
137
+ data = self._client.get("/v1/skills/invocations/mine")
138
+ return [_to_invocation(i) for i in data]
139
+
140
+ def list_pending_invocations(self, skill_id: str | None = None) -> list[SkillInvocation]:
141
+ params = {"skill_id": skill_id} if skill_id else {}
142
+ data = self._client.get("/v1/skills/invocations/pending", params=params)
143
+ return [_to_invocation(i) for i in data]
144
+
145
+ def claim_invocation(self, invocation_id: str) -> dict[str, Any]:
146
+ return self._client.post(f"/v1/skills/invocations/{invocation_id}/claim")
147
+
148
+ def complete_invocation(self, invocation_id: str, output: dict[str, Any]) -> dict[str, Any]:
149
+ return self._client.post(
150
+ f"/v1/skills/invocations/{invocation_id}/complete",
151
+ json={"output": output},
152
+ )
153
+
154
+ def poll(
155
+ self,
156
+ invocation_id: str,
157
+ *,
158
+ interval_seconds: float = 1.0,
159
+ timeout_seconds: float = 60.0,
160
+ ) -> SkillInvocation:
161
+ """Poll an invocation until it reaches a terminal state."""
162
+ start = time.time()
163
+ while time.time() - start < timeout_seconds:
164
+ result = self.get_invocation(invocation_id)
165
+ if result.status in ("completed", "failed", "expired"):
166
+ return result
167
+ time.sleep(interval_seconds)
168
+ raise TimeoutError(f"Invocation {invocation_id} timed out after {timeout_seconds}s")
169
+
170
+ # ─── Requests ────────────────────────────────────────────────────
171
+
172
+ def post_request(self, **kwargs: Any) -> SkillRequest:
173
+ return _to_request(self._client.post("/v1/skill-requests", json=kwargs))
174
+
175
+ def list_requests(
176
+ self,
177
+ *,
178
+ status: str | None = None,
179
+ limit: int | None = None,
180
+ offset: int | None = None,
181
+ ) -> list[SkillRequest]:
182
+ params: dict[str, str] = {}
183
+ if status:
184
+ params["status"] = status
185
+ if limit is not None:
186
+ params["limit"] = str(limit)
187
+ if offset is not None:
188
+ params["offset"] = str(offset)
189
+ data = self._client.get("/v1/skill-requests", params=params)
190
+ return [_to_request(r) for r in data]
191
+
192
+ def get_request(self, request_id: str) -> SkillRequest:
193
+ return _to_request(self._client.get(f"/v1/skill-requests/{request_id}"))
194
+
195
+ def get_request_matches(self, request_id: str) -> dict[str, Any]:
196
+ return self._client.get(f"/v1/skill-requests/{request_id}/matches")
197
+
198
+ def suggest_composition(self, request_id: str) -> dict[str, Any]:
199
+ return self._client.get(f"/v1/skill-requests/{request_id}/suggest")
@@ -154,3 +154,91 @@ class ToolCall:
154
154
  if self.target:
155
155
  d["target"] = self.target
156
156
  return d
157
+
158
+
159
+ # ─── Skills ──────────────────────────────────────────────────────────
160
+
161
+
162
+ @dataclass
163
+ class Skill:
164
+ id: str
165
+ operator_id: str
166
+ name: str
167
+ description: str
168
+ version: str = "1.0.0"
169
+ tags: list[str] = field(default_factory=list)
170
+ trust_level_required: str = "L0"
171
+ status: str = "active"
172
+ execution_mode: str = "open"
173
+ price_per_invocation: int = 0
174
+ invocation_count: int = 0
175
+ created_at: str = ""
176
+ updated_at: str = ""
177
+
178
+
179
+ @dataclass
180
+ class SkillInvocation:
181
+ id: str
182
+ skill_id: str
183
+ status: str
184
+ output: Any = None
185
+ error: dict[str, str] | None = None
186
+ expires_at: str = ""
187
+ created_at: str = ""
188
+ started_at: str | None = None
189
+ completed_at: str | None = None
190
+ llm_tokens_input: int | None = None
191
+ llm_tokens_output: int | None = None
192
+ execution_duration_ms: int | None = None
193
+
194
+
195
+ @dataclass
196
+ class SkillRequest:
197
+ id: str
198
+ operator_id: str
199
+ description: str
200
+ desired_input_schema: dict[str, Any] | None = None
201
+ desired_output_schema: dict[str, Any] | None = None
202
+ max_price_cents: int | None = None
203
+ tags: list[str] = field(default_factory=list)
204
+ status: str = "open"
205
+ created_at: str = ""
206
+
207
+
208
+ # ─── Identity ────────────────────────────────────────────────────────
209
+
210
+
211
+ @dataclass
212
+ class IdentityProvider:
213
+ key: str
214
+ name: str
215
+ layers: list[str] = field(default_factory=list)
216
+ assurance: str = ""
217
+ description: str = ""
218
+
219
+
220
+ @dataclass
221
+ class IdentityClaim:
222
+ id: str
223
+ operator_id: str
224
+ provider_key: str
225
+ layer: str
226
+ claim_type: str
227
+ claim_ref: str
228
+ assurance_level: str
229
+ verified_at: str
230
+ expires_at: str | None = None
231
+
232
+
233
+ @dataclass
234
+ class IdentitySettings:
235
+ identity_claim_ttl: str # 30d | 90d | 180d | 1y
236
+ identity_claim_inheritance: str # auto | opt_in
237
+ identity_auto_revoke: bool
238
+
239
+
240
+ @dataclass
241
+ class VerificationSession:
242
+ session_ref: str
243
+ authorization_url: str | None = None
244
+ status: str = "pending"
getstack-0.1.0/.gitignore DELETED
@@ -1,38 +0,0 @@
1
- node_modules/
2
- dist/
3
- .turbo/
4
- *.tsbuildinfo
5
-
6
- # Environment / secrets
7
- .env
8
- .env.*
9
- *.env
10
-
11
- # Strategy docs (not sensitive, but private)
12
- stack-claude-code-spec-v3.md
13
- stack-gtm-distribution.md
14
- stack-platform-spec-v2.md
15
-
16
- # IDE
17
- .vscode/settings.json
18
- .idea/
19
-
20
- # OS
21
- .DS_Store
22
- Thumbs.db
23
-
24
- # Next.js
25
- .next/
26
- out/
27
-
28
- # MCP publisher
29
- mcp-publisher.exe
30
- .mcpregistry_*
31
-
32
- # Fly.io deployment config (contains app names, regions)
33
- **/fly.toml
34
- !**/fly.toml.example
35
-
36
- # Logs
37
- *.log
38
- npm-debug.log*
@@ -1,26 +0,0 @@
1
- """Audit log access."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Any
6
-
7
- from .client import HttpClient
8
-
9
-
10
- class AuditService:
11
- def __init__(self, client: HttpClient):
12
- self._client = client
13
-
14
- def list(
15
- self,
16
- page: int = 1,
17
- limit: int = 50,
18
- action: str | None = None,
19
- agent_id: str | None = None,
20
- ) -> list[dict[str, Any]]:
21
- params: dict[str, str] = {"page": str(page), "limit": str(limit)}
22
- if action:
23
- params["action"] = action
24
- if agent_id:
25
- params["agent_id"] = agent_id
26
- return self._client.get("/v1/audit", params=params)
@@ -1,33 +0,0 @@
1
- """Credential retrieval."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Any
6
- from urllib.parse import quote
7
-
8
- from .client import HttpClient
9
- from .types import Credential
10
-
11
-
12
- class CredentialService:
13
- def __init__(self, client: HttpClient):
14
- self._client = client
15
-
16
- def get(self, provider: str, passport: Any | None = None) -> Credential:
17
- headers_extra = {}
18
- if passport and hasattr(passport, "token"):
19
- headers_extra["X-Passport-Token"] = passport.token
20
- data = self._client.get(f"/v1/credentials/{quote(provider)}")
21
- return Credential(
22
- provider=data["provider"],
23
- credential=data.get("credential"),
24
- credentials=data.get("credentials"),
25
- )
26
-
27
- def get_by_connection(self, connection_id: str) -> Credential:
28
- data = self._client.get(f"/v1/credentials/by-connection/{connection_id}")
29
- return Credential(
30
- provider=data["provider"],
31
- credential=data.get("credential"),
32
- credentials=data.get("credentials"),
33
- )
File without changes
File without changes
File without changes
File without changes