getstack 0.1.0__tar.gz → 0.3.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 (30) hide show
  1. getstack-0.3.0/.gitignore +61 -0
  2. getstack-0.3.0/CLAUDE.md +46 -0
  3. {getstack-0.1.0 → getstack-0.3.0}/PKG-INFO +36 -16
  4. {getstack-0.1.0 → getstack-0.3.0}/README.md +33 -15
  5. {getstack-0.1.0 → getstack-0.3.0}/pyproject.toml +9 -1
  6. {getstack-0.1.0 → getstack-0.3.0}/src/getstack/__init__.py +99 -12
  7. getstack-0.3.0/src/getstack/agent_auth.py +189 -0
  8. getstack-0.3.0/src/getstack/audit.py +56 -0
  9. getstack-0.3.0/src/getstack/auth.py +157 -0
  10. {getstack-0.1.0 → getstack-0.3.0}/src/getstack/client.py +4 -3
  11. getstack-0.3.0/src/getstack/credentials.py +44 -0
  12. getstack-0.3.0/src/getstack/identity.py +119 -0
  13. {getstack-0.1.0 → getstack-0.3.0}/src/getstack/notifications.py +10 -5
  14. {getstack-0.1.0 → getstack-0.3.0}/src/getstack/passports.py +44 -0
  15. getstack-0.3.0/src/getstack/proxy.py +109 -0
  16. getstack-0.3.0/src/getstack/scan.py +77 -0
  17. getstack-0.3.0/src/getstack/security_events.py +27 -0
  18. getstack-0.3.0/src/getstack/skills.py +199 -0
  19. {getstack-0.1.0 → getstack-0.3.0}/src/getstack/types.py +88 -0
  20. getstack-0.1.0/.gitignore +0 -38
  21. getstack-0.1.0/src/getstack/audit.py +0 -26
  22. getstack-0.1.0/src/getstack/auth.py +0 -78
  23. getstack-0.1.0/src/getstack/credentials.py +0 -33
  24. {getstack-0.1.0 → getstack-0.3.0}/LICENSE +0 -0
  25. {getstack-0.1.0 → getstack-0.3.0}/src/getstack/agents.py +0 -0
  26. {getstack-0.1.0 → getstack-0.3.0}/src/getstack/dropoffs.py +0 -0
  27. {getstack-0.1.0 → getstack-0.3.0}/src/getstack/errors.py +0 -0
  28. {getstack-0.1.0 → getstack-0.3.0}/src/getstack/py.typed +0 -0
  29. {getstack-0.1.0 → getstack-0.3.0}/src/getstack/reviews.py +0 -0
  30. {getstack-0.1.0 → getstack-0.3.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,46 @@
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
+ - User-facing quickstart + examples: `sdk-python/README.md`
19
+ - License: `sdk-python/LICENSE`
20
+
21
+ ## Key divergence from JS SDK
22
+
23
+ The Python SDK has a **mission context manager** the JS SDK doesn't have:
24
+
25
+ ```python
26
+ with stack.passports.mission(
27
+ agent_id=agent.id,
28
+ intent="Process invoices from Slack",
29
+ services=["slack", "stripe"],
30
+ checkpoint_interval="5m",
31
+ ) as mission:
32
+ mission.log("slack", "read_channel", "#invoices")
33
+ mission.log("stripe", "create_invoice")
34
+ # Checkpoints submitted automatically on schedule
35
+ # Checkout submitted automatically when the block exits
36
+ ```
37
+
38
+ When the JS SDK adds an equivalent, align the shape (same keyword args,
39
+ same method names) for cross-language familiarity.
40
+
41
+ ## Gotchas
42
+
43
+ - **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.
44
+ - **Published to PyPI separately.** There's no coordinated release between the JS + Python SDKs; they can (and sometimes do) drift in version numbers.
45
+ - **Same authentication posture.** API keys, session JWTs, and the same `sk_live_*` prefix convention. Token type detection works identically to JS.
46
+ - **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.3.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
@@ -19,7 +19,9 @@ Classifier: Programming Language :: Python :: 3.11
19
19
  Classifier: Programming Language :: Python :: 3.12
20
20
  Classifier: Topic :: Software Development :: Libraries
21
21
  Requires-Python: >=3.9
22
+ Requires-Dist: cryptography>=42.0.0
22
23
  Requires-Dist: httpx>=0.25.0
24
+ Requires-Dist: pyjwt>=2.8.0
23
25
  Description-Content-Type: text/markdown
24
26
 
25
27
  # getstack
@@ -34,16 +36,30 @@ pip install getstack
34
36
 
35
37
  ## Quick start
36
38
 
39
+ Sign in once on your machine — the SDK reads credentials from `~/.stack/credentials.json` automatically:
40
+
41
+ ```bash
42
+ npx -y @getstackrun/cli auth login
43
+ ```
44
+
37
45
  ```python
38
46
  from getstack import Stack
39
47
 
40
- stack = Stack(api_key="sk_live_...")
48
+ # Zero-arg constructor reads ~/.stack/credentials.json (OAuth refresh token).
49
+ # Falls back to STACK_API_KEY env var, then constructor api_key= for CI.
50
+ stack = Stack()
41
51
 
42
- # Register an agent
52
+ # 1. Register an agent (one-time)
43
53
  agent = stack.agents.register("my-agent", accountability_mode="enforced")
44
54
 
45
- # Run a mission with automatic checkpoints and checkout
46
- with stack.passports.mission(
55
+ # 2. In your agent runtime, switch to per-agent keypair mode
56
+ agent_stack = Stack(agent_id=agent.id)
57
+ # First run: generates an Ed25519 keypair locally + enrolls the public
58
+ # half via /v1/agents/<id>/enroll. Persisted at ~/.stack/agents/<id>.json
59
+ # (mode 0600). Every subsequent call signs a fresh 60-second JWT.
60
+
61
+ # 3. Run a mission with automatic checkpoints and checkout
62
+ with agent_stack.passports.mission(
47
63
  agent_id=agent.id,
48
64
  intent="Process invoices from Slack",
49
65
  services=["slack", "stripe"],
@@ -57,22 +73,26 @@ with stack.passports.mission(
57
73
 
58
74
  ## Authentication
59
75
 
76
+ Four sources, resolved in priority order:
77
+
60
78
  ```python
61
- # API key (most common)
62
- stack = Stack(api_key="sk_live_...")
79
+ # 1. Explicit auth strategy
80
+ stack = Stack.from_oauth(client_id="...", client_secret="", access_token="...", refresh_token="...")
81
+ stack = Stack.from_session(session_token="...")
63
82
 
64
- # OAuth with auto-refresh
65
- stack = Stack.from_oauth(
66
- client_id="...",
67
- client_secret="...",
68
- access_token="...",
69
- refresh_token="...",
70
- )
83
+ # 2. agent_id (Phase 2 — recommended for production runtimes)
84
+ stack = Stack(agent_id="agt_xxx")
71
85
 
72
- # Session JWT (dashboard integrations)
73
- stack = Stack.from_session(session_token="...")
86
+ # 3. api_key (legacy sk_live_*; for CI without a browser)
87
+ stack = Stack(api_key="sk_live_...")
88
+ # or set STACK_API_KEY in the environment
89
+
90
+ # 4. ~/.stack/credentials.json (Phase 1 — `stack-cli auth login` writes it)
91
+ stack = Stack()
74
92
  ```
75
93
 
94
+ See [/docs/security/stack-auth](https://getstack.run/docs/security/stack-auth) for the full auth model and [/docs/security/agent-keys](https://getstack.run/docs/security/agent-keys) for the per-agent keypair story.
95
+
76
96
  ## Continuous missions (24/7 agents)
77
97
 
78
98
  ```python
@@ -10,16 +10,30 @@ pip install getstack
10
10
 
11
11
  ## Quick start
12
12
 
13
+ Sign in once on your machine — the SDK reads credentials from `~/.stack/credentials.json` automatically:
14
+
15
+ ```bash
16
+ npx -y @getstackrun/cli auth login
17
+ ```
18
+
13
19
  ```python
14
20
  from getstack import Stack
15
21
 
16
- stack = Stack(api_key="sk_live_...")
22
+ # Zero-arg constructor reads ~/.stack/credentials.json (OAuth refresh token).
23
+ # Falls back to STACK_API_KEY env var, then constructor api_key= for CI.
24
+ stack = Stack()
17
25
 
18
- # Register an agent
26
+ # 1. Register an agent (one-time)
19
27
  agent = stack.agents.register("my-agent", accountability_mode="enforced")
20
28
 
21
- # Run a mission with automatic checkpoints and checkout
22
- with stack.passports.mission(
29
+ # 2. In your agent runtime, switch to per-agent keypair mode
30
+ agent_stack = Stack(agent_id=agent.id)
31
+ # First run: generates an Ed25519 keypair locally + enrolls the public
32
+ # half via /v1/agents/<id>/enroll. Persisted at ~/.stack/agents/<id>.json
33
+ # (mode 0600). Every subsequent call signs a fresh 60-second JWT.
34
+
35
+ # 3. Run a mission with automatic checkpoints and checkout
36
+ with agent_stack.passports.mission(
23
37
  agent_id=agent.id,
24
38
  intent="Process invoices from Slack",
25
39
  services=["slack", "stripe"],
@@ -33,22 +47,26 @@ with stack.passports.mission(
33
47
 
34
48
  ## Authentication
35
49
 
50
+ Four sources, resolved in priority order:
51
+
36
52
  ```python
37
- # API key (most common)
38
- stack = Stack(api_key="sk_live_...")
53
+ # 1. Explicit auth strategy
54
+ stack = Stack.from_oauth(client_id="...", client_secret="", access_token="...", refresh_token="...")
55
+ stack = Stack.from_session(session_token="...")
39
56
 
40
- # OAuth with auto-refresh
41
- stack = Stack.from_oauth(
42
- client_id="...",
43
- client_secret="...",
44
- access_token="...",
45
- refresh_token="...",
46
- )
57
+ # 2. agent_id (Phase 2 — recommended for production runtimes)
58
+ stack = Stack(agent_id="agt_xxx")
47
59
 
48
- # Session JWT (dashboard integrations)
49
- stack = Stack.from_session(session_token="...")
60
+ # 3. api_key (legacy sk_live_*; for CI without a browser)
61
+ stack = Stack(api_key="sk_live_...")
62
+ # or set STACK_API_KEY in the environment
63
+
64
+ # 4. ~/.stack/credentials.json (Phase 1 — `stack-cli auth login` writes it)
65
+ stack = Stack()
50
66
  ```
51
67
 
68
+ See [/docs/security/stack-auth](https://getstack.run/docs/security/stack-auth) for the full auth model and [/docs/security/agent-keys](https://getstack.run/docs/security/agent-keys) for the per-agent keypair story.
69
+
52
70
  ## Continuous missions (24/7 agents)
53
71
 
54
72
  ```python
@@ -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.3.0"
8
8
  description = "Python SDK for STACK — trust infrastructure for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -24,6 +24,14 @@ classifiers = [
24
24
  ]
25
25
  dependencies = [
26
26
  "httpx>=0.25.0",
27
+ # Phase 2 — agent-keypair runtime needs Ed25519 sign + verify and
28
+ # JWT mint. cryptography is the de-facto standalone Ed25519 lib in
29
+ # the Python ecosystem; ~10MB install. Pinned to a major to avoid
30
+ # surprise breakage but minor floats.
31
+ "cryptography>=42.0.0",
32
+ # PyJWT for agent JWT minting; pure-Python, tiny (~50KB), no native
33
+ # deps beyond cryptography (which we already need).
34
+ "pyjwt>=2.8.0",
27
35
  ]
28
36
 
29
37
  [project.urls]
@@ -33,7 +33,10 @@ For async usage::
33
33
 
34
34
  from __future__ import annotations
35
35
 
36
- from .auth import ApiKeyAuth, SessionAuth, OAuthAuth
36
+ import os
37
+
38
+ from .auth import ApiKeyAuth, SessionAuth, OAuthAuth, CredentialsFileAuth
39
+ from .agent_auth import AgentKeypairAuth
37
40
  from .client import HttpClient, AsyncHttpClient, DEFAULT_BASE_URL
38
41
  from .agents import AgentService
39
42
  from .passports import PassportService, Mission
@@ -43,6 +46,11 @@ from .dropoffs import DropoffService
43
46
  from .reviews import ReviewService
44
47
  from .notifications import NotificationService
45
48
  from .audit import AuditService
49
+ from .proxy import ProxyService as ProxySvc, ProxyResponse
50
+ from .scan import ScanService
51
+ from .security_events import SecurityEventService as SecurityEventSvc
52
+ from .skills import SkillService
53
+ from .identity import IdentityService
46
54
  from .types import (
47
55
  Agent,
48
56
  Passport,
@@ -56,6 +64,13 @@ from .types import (
56
64
  Credential,
57
65
  NotificationChannel,
58
66
  ToolCall,
67
+ Skill,
68
+ SkillInvocation,
69
+ SkillRequest,
70
+ IdentityProvider,
71
+ IdentityClaim,
72
+ IdentitySettings,
73
+ VerificationSession,
59
74
  )
60
75
  from .errors import (
61
76
  StackError,
@@ -66,7 +81,30 @@ from .errors import (
66
81
  PassportBlockedError,
67
82
  )
68
83
 
69
- __version__ = "0.1.0"
84
+ __version__ = "0.2.0"
85
+
86
+
87
+ def _build_static_auth(
88
+ *,
89
+ api_key: str | None,
90
+ base_url: str,
91
+ ) -> ApiKeyAuth | CredentialsFileAuth:
92
+ """Phase 2 helper. Resolve a static-bearer auth strategy for the
93
+ one-time agent-enrollment dance — explicit api_key, STACK_API_KEY
94
+ env var, or ~/.stack/credentials.json (CLI-managed OAuth refresh).
95
+ """
96
+ if api_key:
97
+ return ApiKeyAuth(api_key)
98
+ env_key = os.environ.get("STACK_API_KEY")
99
+ if env_key:
100
+ return ApiKeyAuth(env_key)
101
+ try:
102
+ return CredentialsFileAuth(api_base_url=base_url)
103
+ except RuntimeError as e:
104
+ raise ValueError(
105
+ "Agent enrollment needs a static bearer (api_key=, "
106
+ "STACK_API_KEY, or `stack-cli auth login`) — none found."
107
+ ) from e
70
108
 
71
109
  __all__ = [
72
110
  "Stack",
@@ -86,6 +124,14 @@ __all__ = [
86
124
  "NotificationChannel",
87
125
  "Mission",
88
126
  "ToolCall",
127
+ "ProxyResponse",
128
+ "Skill",
129
+ "SkillInvocation",
130
+ "SkillRequest",
131
+ "IdentityProvider",
132
+ "IdentityClaim",
133
+ "IdentitySettings",
134
+ "VerificationSession",
89
135
  # Errors
90
136
  "StackError",
91
137
  "NotFoundError",
@@ -109,16 +155,37 @@ class Stack:
109
155
  self,
110
156
  api_key: str | None = None,
111
157
  *,
158
+ agent_id: str | None = None,
112
159
  base_url: str = DEFAULT_BASE_URL,
113
160
  timeout: float = 30.0,
114
- _auth: ApiKeyAuth | SessionAuth | OAuthAuth | None = None,
161
+ _auth: ApiKeyAuth | SessionAuth | OAuthAuth | CredentialsFileAuth | AgentKeypairAuth | None = None,
115
162
  ):
163
+ # Four-source resolution, in priority order:
164
+ # 1. Explicit _auth (callers that pre-built a strategy).
165
+ # 2. agent_id (Phase 2 — agent-keypair runtime mode). Static
166
+ # bearer is auto-resolved for enrollment via api_key/env/file.
167
+ # 3. api_key arg or STACK_API_KEY env var (legacy sk_live_*).
168
+ # 4. ~/.stack/credentials.json — OAuth refresh token written
169
+ # by `stack-cli auth login`.
116
170
  if _auth:
117
- auth = _auth
118
- elif api_key:
119
- auth = ApiKeyAuth(api_key)
171
+ auth: ApiKeyAuth | SessionAuth | OAuthAuth | CredentialsFileAuth | AgentKeypairAuth = _auth
172
+ elif agent_id:
173
+ static_auth = _build_static_auth(api_key=api_key, base_url=base_url)
174
+ auth = AgentKeypairAuth(
175
+ agent_id=agent_id,
176
+ api_base_url=base_url,
177
+ bearer_provider=lambda: static_auth.get_headers()["Authorization"].split(" ", 1)[1],
178
+ )
179
+ elif api_key or os.environ.get("STACK_API_KEY"):
180
+ auth = ApiKeyAuth(api_key or os.environ["STACK_API_KEY"])
120
181
  else:
121
- raise ValueError("Either api_key or _auth must be provided")
182
+ try:
183
+ auth = CredentialsFileAuth(api_base_url=base_url)
184
+ except RuntimeError as e:
185
+ raise ValueError(
186
+ "No STACK credentials found. Pass api_key=, set "
187
+ "STACK_API_KEY, or run `stack-cli auth login`."
188
+ ) from e
122
189
 
123
190
  self._client = HttpClient(auth, base_url=base_url, timeout=timeout)
124
191
  self.agents = AgentService(self._client)
@@ -129,6 +196,11 @@ class Stack:
129
196
  self.reviews = ReviewService(self._client)
130
197
  self.notifications = NotificationService(self._client)
131
198
  self.audit = AuditService(self._client)
199
+ self.proxy = ProxySvc(self._client)
200
+ self.scan = ScanService(self._client)
201
+ self.security_events = SecurityEventSvc(self._client)
202
+ self.skills = SkillService(self._client)
203
+ self.identity = IdentityService(self._client)
132
204
 
133
205
  @classmethod
134
206
  def from_session(cls, session_token: str, **kwargs) -> Stack:
@@ -184,16 +256,31 @@ class AsyncStack:
184
256
  self,
185
257
  api_key: str | None = None,
186
258
  *,
259
+ agent_id: str | None = None,
187
260
  base_url: str = DEFAULT_BASE_URL,
188
261
  timeout: float = 30.0,
189
- _auth: ApiKeyAuth | SessionAuth | OAuthAuth | None = None,
262
+ _auth: ApiKeyAuth | SessionAuth | OAuthAuth | CredentialsFileAuth | AgentKeypairAuth | None = None,
190
263
  ):
264
+ # Same four-source resolution as Stack — see Stack.__init__.
191
265
  if _auth:
192
- auth = _auth
193
- elif api_key:
194
- auth = ApiKeyAuth(api_key)
266
+ auth: ApiKeyAuth | SessionAuth | OAuthAuth | CredentialsFileAuth | AgentKeypairAuth = _auth
267
+ elif agent_id:
268
+ static_auth = _build_static_auth(api_key=api_key, base_url=base_url)
269
+ auth = AgentKeypairAuth(
270
+ agent_id=agent_id,
271
+ api_base_url=base_url,
272
+ bearer_provider=lambda: static_auth.get_headers()["Authorization"].split(" ", 1)[1],
273
+ )
274
+ elif api_key or os.environ.get("STACK_API_KEY"):
275
+ auth = ApiKeyAuth(api_key or os.environ["STACK_API_KEY"])
195
276
  else:
196
- raise ValueError("Either api_key or _auth must be provided")
277
+ try:
278
+ auth = CredentialsFileAuth(api_base_url=base_url)
279
+ except RuntimeError as e:
280
+ raise ValueError(
281
+ "No STACK credentials found. Pass api_key=, set "
282
+ "STACK_API_KEY, or run `stack-cli auth login`."
283
+ ) from e
197
284
 
198
285
  self._client = AsyncHttpClient(auth, base_url=base_url, timeout=timeout)
199
286
 
@@ -0,0 +1,189 @@
1
+ """Phase 2 — agent-keypair runtime auth (Python).
2
+
3
+ Mirror of packages/sdk/src/agent-auth.ts. When ``Stack(agent_id=...)`` is
4
+ constructed, the SDK enters agent-runtime mode: every request is signed
5
+ with a fresh 60-second EdDSA JWT minted from the agent's local privkey
6
+ at ~/.stack/agents/<agent_id>.json.
7
+
8
+ First run: generate Ed25519 keypair, request enrollment challenge from
9
+ the API, sign challenge with privkey, POST /enroll. Persist privkey
10
+ locally (mode 0600). Subsequent runs read from disk.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import json
17
+ import os
18
+ import time
19
+ from pathlib import Path
20
+ from typing import Callable
21
+
22
+ import httpx
23
+ import jwt
24
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
25
+ Ed25519PrivateKey,
26
+ Ed25519PublicKey,
27
+ )
28
+ from cryptography.hazmat.primitives.serialization import (
29
+ Encoding,
30
+ NoEncryption,
31
+ PrivateFormat,
32
+ PublicFormat,
33
+ )
34
+
35
+ from .auth import AuthStrategy
36
+
37
+ AGENT_JWT_TTL = 60
38
+ AGENT_JWT_ISSUER = "stack-sdk"
39
+ AGENT_JWT_AUDIENCE = "stack:agent"
40
+
41
+
42
+ def _b64url_no_pad(data: bytes) -> str:
43
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
44
+
45
+
46
+ def _key_path(agent_id: str) -> Path:
47
+ return Path(os.path.expanduser("~")) / ".stack" / "agents" / f"{agent_id}.json"
48
+
49
+
50
+ def _load_stored(agent_id: str) -> dict | None:
51
+ path = _key_path(agent_id)
52
+ if not path.exists():
53
+ return None
54
+ try:
55
+ return json.loads(path.read_text(encoding="utf8"))
56
+ except (OSError, json.JSONDecodeError):
57
+ return None
58
+
59
+
60
+ def _persist_stored(agent_id: str, payload: dict) -> None:
61
+ path = _key_path(agent_id)
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ path.write_text(json.dumps(payload, indent=2), encoding="utf8")
64
+ try:
65
+ os.chmod(path, 0o600)
66
+ except OSError:
67
+ # Windows / unsupported FS — ignore.
68
+ pass
69
+
70
+
71
+ def _generate_keypair() -> tuple[Ed25519PrivateKey, dict, dict]:
72
+ """Generate an Ed25519 keypair and return (privkey_obj, public_jwk,
73
+ private_jwk_with_d)."""
74
+ priv = Ed25519PrivateKey.generate()
75
+ pub = priv.public_key()
76
+ pub_raw = pub.public_bytes(Encoding.Raw, PublicFormat.Raw)
77
+ priv_raw = priv.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
78
+ public_jwk = {"kty": "OKP", "crv": "Ed25519", "x": _b64url_no_pad(pub_raw)}
79
+ private_jwk = {**public_jwk, "d": _b64url_no_pad(priv_raw)}
80
+ return priv, public_jwk, private_jwk
81
+
82
+
83
+ def _privkey_from_jwk(jwk: dict) -> Ed25519PrivateKey:
84
+ pad = "=" * (-len(jwk["d"]) % 4)
85
+ raw = base64.urlsafe_b64decode(jwk["d"] + pad)
86
+ return Ed25519PrivateKey.from_private_bytes(raw)
87
+
88
+
89
+ def _enroll(
90
+ agent_id: str,
91
+ base_url: str,
92
+ bearer_provider: Callable[[], str],
93
+ ) -> dict:
94
+ """Run the proof-of-possession enrollment dance. Returns the stored
95
+ payload (with private JWK) ready to persist."""
96
+ bearer = bearer_provider()
97
+ base = base_url.rstrip("/")
98
+
99
+ # Step 1 — request challenge.
100
+ r = httpx.post(
101
+ f"{base}/v1/agents/{agent_id}/enrollment-challenge",
102
+ headers={"Authorization": f"Bearer {bearer}", "Content-Type": "application/json"},
103
+ timeout=15.0,
104
+ )
105
+ r.raise_for_status()
106
+ challenge_body = r.json()
107
+ challenge: str = challenge_body["challenge"]
108
+ challenge_id: str = challenge_body["challenge_id"]
109
+
110
+ # Step 2 — generate keypair locally.
111
+ priv_obj, public_jwk, private_jwk = _generate_keypair()
112
+
113
+ # Step 3 — sign the challenge bytes.
114
+ sig = priv_obj.sign(challenge.encode("utf8"))
115
+ signed_challenge = _b64url_no_pad(sig)
116
+
117
+ # Step 4 — POST /enroll.
118
+ r2 = httpx.post(
119
+ f"{base}/v1/agents/{agent_id}/enroll",
120
+ headers={"Authorization": f"Bearer {bearer}", "Content-Type": "application/json"},
121
+ json={
122
+ "public_key": public_jwk,
123
+ "challenge_id": challenge_id,
124
+ "signed_challenge": signed_challenge,
125
+ },
126
+ timeout=15.0,
127
+ )
128
+ r2.raise_for_status()
129
+ enrolled = r2.json()
130
+
131
+ return {
132
+ "agent_id": agent_id,
133
+ "publicKey": public_jwk,
134
+ "privateKey": private_jwk,
135
+ "enrolled_at": enrolled.get("enrolled_at"),
136
+ }
137
+
138
+
139
+ def _sign_agent_jwt(stored: dict, agent_id: str) -> str:
140
+ """Mint a fresh 60-second EdDSA JWT signed with the agent privkey."""
141
+ priv_obj = _privkey_from_jwk(stored["privateKey"])
142
+ now = int(time.time())
143
+ payload = {
144
+ "iss": AGENT_JWT_ISSUER,
145
+ "sub": agent_id,
146
+ "aud": AGENT_JWT_AUDIENCE,
147
+ "iat": now,
148
+ "nbf": now,
149
+ "exp": now + AGENT_JWT_TTL,
150
+ "jti": f"aj_{now}_{os.urandom(6).hex()}",
151
+ }
152
+ # PyJWT accepts a raw cryptography Ed25519 key when algorithm='EdDSA'.
153
+ return jwt.encode(payload, priv_obj, algorithm="EdDSA")
154
+
155
+
156
+ class AgentKeypairAuth(AuthStrategy):
157
+ """Phase 2 agent-runtime auth strategy.
158
+
159
+ Constructed with an agent_id and a fallback bearer provider (used
160
+ only for the one-time enrollment dance). Once enrolled, every
161
+ request mints a fresh 60-second JWT signed by the locally-stored
162
+ privkey — the bearer provider is no longer consulted.
163
+ """
164
+
165
+ def __init__(
166
+ self,
167
+ agent_id: str,
168
+ api_base_url: str,
169
+ bearer_provider: Callable[[], str],
170
+ ):
171
+ self.agent_id = agent_id
172
+ self.api_base_url = api_base_url
173
+ self.bearer_provider = bearer_provider
174
+ self._stored: dict | None = None
175
+
176
+ def _ensure_stored(self) -> dict:
177
+ if self._stored is not None:
178
+ return self._stored
179
+ stored = _load_stored(self.agent_id)
180
+ if stored is None:
181
+ stored = _enroll(self.agent_id, self.api_base_url, self.bearer_provider)
182
+ _persist_stored(self.agent_id, stored)
183
+ self._stored = stored
184
+ return stored
185
+
186
+ def get_headers(self) -> dict[str, str]:
187
+ stored = self._ensure_stored()
188
+ token = _sign_agent_jwt(stored, self.agent_id)
189
+ return {"Authorization": f"Bearer {token}"}