getstack 0.3.0__tar.gz → 0.5.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 (29) hide show
  1. {getstack-0.3.0 → getstack-0.5.0}/.gitignore +7 -1
  2. {getstack-0.3.0 → getstack-0.5.0}/PKG-INFO +1 -1
  3. {getstack-0.3.0 → getstack-0.5.0}/pyproject.toml +1 -1
  4. {getstack-0.3.0 → getstack-0.5.0}/src/getstack/__init__.py +27 -18
  5. getstack-0.5.0/src/getstack/agent_auth.py +331 -0
  6. getstack-0.5.0/src/getstack/browser_bootstrap.py +212 -0
  7. getstack-0.5.0/src/getstack/intents.py +196 -0
  8. getstack-0.3.0/src/getstack/agent_auth.py +0 -189
  9. {getstack-0.3.0 → getstack-0.5.0}/CLAUDE.md +0 -0
  10. {getstack-0.3.0 → getstack-0.5.0}/LICENSE +0 -0
  11. {getstack-0.3.0 → getstack-0.5.0}/README.md +0 -0
  12. {getstack-0.3.0 → getstack-0.5.0}/src/getstack/agents.py +0 -0
  13. {getstack-0.3.0 → getstack-0.5.0}/src/getstack/audit.py +0 -0
  14. {getstack-0.3.0 → getstack-0.5.0}/src/getstack/auth.py +0 -0
  15. {getstack-0.3.0 → getstack-0.5.0}/src/getstack/client.py +0 -0
  16. {getstack-0.3.0 → getstack-0.5.0}/src/getstack/credentials.py +0 -0
  17. {getstack-0.3.0 → getstack-0.5.0}/src/getstack/dropoffs.py +0 -0
  18. {getstack-0.3.0 → getstack-0.5.0}/src/getstack/errors.py +0 -0
  19. {getstack-0.3.0 → getstack-0.5.0}/src/getstack/identity.py +0 -0
  20. {getstack-0.3.0 → getstack-0.5.0}/src/getstack/notifications.py +0 -0
  21. {getstack-0.3.0 → getstack-0.5.0}/src/getstack/passports.py +0 -0
  22. {getstack-0.3.0 → getstack-0.5.0}/src/getstack/proxy.py +0 -0
  23. {getstack-0.3.0 → getstack-0.5.0}/src/getstack/py.typed +0 -0
  24. {getstack-0.3.0 → getstack-0.5.0}/src/getstack/reviews.py +0 -0
  25. {getstack-0.3.0 → getstack-0.5.0}/src/getstack/scan.py +0 -0
  26. {getstack-0.3.0 → getstack-0.5.0}/src/getstack/security_events.py +0 -0
  27. {getstack-0.3.0 → getstack-0.5.0}/src/getstack/services.py +0 -0
  28. {getstack-0.3.0 → getstack-0.5.0}/src/getstack/skills.py +0 -0
  29. {getstack-0.3.0 → getstack-0.5.0}/src/getstack/types.py +0 -0
@@ -9,9 +9,15 @@ __pycache__/
9
9
  .env
10
10
  .env.*
11
11
  *.env
12
- .npmrc
13
12
  scripts/.env.cto
14
13
 
14
+ # Repo .npmrc files carry supply-chain policy (minimum-release-age,
15
+ # onlyBuiltDependencies hints, auto-install-peers) and MUST be tracked
16
+ # so CI + Docker + every developer inherits the same gate. Block only
17
+ # user-local override files that conventionally carry auth tokens.
18
+ .npmrc.local
19
+ ~/.npmrc
20
+
15
21
  # Strategy docs (not sensitive, but private)
16
22
  stack-claude-code-spec-v3.md
17
23
  stack-gtm-distribution.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: getstack
3
- Version: 0.3.0
3
+ Version: 0.5.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.3.0"
7
+ version = "0.5.0"
8
8
  description = "Python SDK for STACK — trust infrastructure for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -51,6 +51,7 @@ from .scan import ScanService
51
51
  from .security_events import SecurityEventService as SecurityEventSvc
52
52
  from .skills import SkillService
53
53
  from .identity import IdentityService
54
+ from .intents import IntentsService
54
55
  from .types import (
55
56
  Agent,
56
57
  Passport,
@@ -98,13 +99,32 @@ def _build_static_auth(
98
99
  env_key = os.environ.get("STACK_API_KEY")
99
100
  if env_key:
100
101
  return ApiKeyAuth(env_key)
102
+ return _resolve_creds_or_bootstrap(base_url)
103
+
104
+
105
+ def _resolve_creds_or_bootstrap(base_url: str) -> CredentialsFileAuth:
106
+ """Read ~/.stack/credentials.json — and if it's missing AND the
107
+ process looks interactive (TTY + not CI), spawn the browser-spawn
108
+ OAuth bootstrap to create it. Raises ValueError when neither path
109
+ resolves.
110
+ """
101
111
  try:
102
112
  return CredentialsFileAuth(api_base_url=base_url)
103
- except RuntimeError as e:
113
+ except RuntimeError:
114
+ from .browser_bootstrap import browser_bootstrap, should_run_browser_bootstrap
115
+ if should_run_browser_bootstrap():
116
+ browser_bootstrap(base_url)
117
+ try:
118
+ return CredentialsFileAuth(api_base_url=base_url)
119
+ except RuntimeError as e:
120
+ raise ValueError(
121
+ "STACK SDK: browser bootstrap reported success but "
122
+ "credentials file still missing."
123
+ ) from e
104
124
  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
125
+ "No STACK credentials found. Pass api_key=, set "
126
+ "STACK_API_KEY, or run `stack-cli auth login`."
127
+ )
108
128
 
109
129
  __all__ = [
110
130
  "Stack",
@@ -179,13 +199,7 @@ class Stack:
179
199
  elif api_key or os.environ.get("STACK_API_KEY"):
180
200
  auth = ApiKeyAuth(api_key or os.environ["STACK_API_KEY"])
181
201
  else:
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
202
+ auth = _resolve_creds_or_bootstrap(base_url)
189
203
 
190
204
  self._client = HttpClient(auth, base_url=base_url, timeout=timeout)
191
205
  self.agents = AgentService(self._client)
@@ -201,6 +215,7 @@ class Stack:
201
215
  self.security_events = SecurityEventSvc(self._client)
202
216
  self.skills = SkillService(self._client)
203
217
  self.identity = IdentityService(self._client)
218
+ self.intents = IntentsService(self._client)
204
219
 
205
220
  @classmethod
206
221
  def from_session(cls, session_token: str, **kwargs) -> Stack:
@@ -274,13 +289,7 @@ class AsyncStack:
274
289
  elif api_key or os.environ.get("STACK_API_KEY"):
275
290
  auth = ApiKeyAuth(api_key or os.environ["STACK_API_KEY"])
276
291
  else:
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
292
+ auth = _resolve_creds_or_bootstrap(base_url)
284
293
 
285
294
  self._client = AsyncHttpClient(auth, base_url=base_url, timeout=timeout)
286
295
 
@@ -0,0 +1,331 @@
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: open the dashboard /sdk-bootstrap page in the user's browser
9
+ for explicit approval (interactive only — TTY + not CI), generate the
10
+ Ed25519 keypair locally, sign the API's challenge, POST /enroll with
11
+ the operator-approved enrollment ticket as Bearer. Persist privkey
12
+ locally (mode 0600). Subsequent runs read from disk.
13
+
14
+ Headless / CI runs (no TTY, or STACK_AUTH_INTERACTIVE=false) skip the
15
+ browser step and fall through to the developer's OAuth bearer (or
16
+ sk_live_* via STACK_API_KEY) for silent enrollment — same endpoints,
17
+ same proof-of-possession, no human in the loop.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ import http.server
24
+ import json
25
+ import os
26
+ import secrets
27
+ import socket
28
+ import socketserver
29
+ import sys
30
+ import threading
31
+ import time
32
+ import urllib.parse
33
+ import webbrowser
34
+ from pathlib import Path
35
+ from typing import Callable
36
+
37
+ import httpx
38
+ import jwt
39
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
40
+ Ed25519PrivateKey,
41
+ Ed25519PublicKey,
42
+ )
43
+ from cryptography.hazmat.primitives.serialization import (
44
+ Encoding,
45
+ NoEncryption,
46
+ PrivateFormat,
47
+ PublicFormat,
48
+ )
49
+
50
+ from .auth import AuthStrategy
51
+ from .browser_bootstrap import should_run_browser_bootstrap
52
+
53
+ AGENT_JWT_TTL = 60
54
+ AGENT_JWT_ISSUER = "stack-sdk"
55
+ AGENT_JWT_AUDIENCE = "stack:agent"
56
+
57
+ ENROLLMENT_AUTH_TIMEOUT_S = 5 * 60 # matches enrollment-ticket TTL
58
+
59
+
60
+ def _dashboard_url(api_base_url: str) -> str:
61
+ """Origin to which the SDK opens the browser for the approval screen.
62
+
63
+ Override via STACK_DASHBOARD_URL for self-hosted/test environments.
64
+ Dev shortcut: api on localhost ⇒ dashboard on :3100.
65
+ """
66
+ override = os.environ.get("STACK_DASHBOARD_URL")
67
+ if override:
68
+ return override
69
+ if api_base_url.startswith(("http://127.0.0.1", "http://localhost")):
70
+ return "http://localhost:3100"
71
+ return "https://getstack.run"
72
+
73
+
74
+ def _ticket_via_browser_approval(agent_id: str, api_base_url: str) -> str:
75
+ """Spawn a one-shot loopback HTTP listener, open the dashboard
76
+ /sdk-bootstrap page in the user's browser, and return the ticket
77
+ once the user approves. Raises RuntimeError on deny / timeout / any
78
+ failure — caller decides whether to surface or fall back.
79
+ """
80
+ state = base64.urlsafe_b64encode(secrets.token_bytes(16)).rstrip(b"=").decode("ascii")
81
+ machine = socket.gethostname()[:80]
82
+ proc_label = f"python {Path(sys.argv[0]).name}"[:80] if sys.argv else "python"
83
+
84
+ holder: dict[str, object | None] = {"ticket": None, "error": None}
85
+ handler_event = threading.Event()
86
+
87
+ class Handler(http.server.BaseHTTPRequestHandler):
88
+ def do_GET(self): # noqa: N802
89
+ parsed = urllib.parse.urlparse(self.path)
90
+ if parsed.path != "/cb":
91
+ self.send_response(404)
92
+ self.end_headers()
93
+ return
94
+ params = urllib.parse.parse_qs(parsed.query)
95
+ cb_state = params.get("state", [None])[0]
96
+ err = params.get("error", [None])[0]
97
+ ticket = params.get("ticket", [None])[0]
98
+ if cb_state != state:
99
+ holder["error"] = "state mismatch"
100
+ self.send_response(400)
101
+ self.send_header("Content-Type", "text/html")
102
+ self.end_headers()
103
+ self.wfile.write(b"<html><body style='font-family:system-ui;padding:40px'><h1>State mismatch</h1></body></html>")
104
+ elif err:
105
+ holder["error"] = err
106
+ self.send_response(403)
107
+ self.send_header("Content-Type", "text/html")
108
+ self.end_headers()
109
+ self.wfile.write(b"<html><body style='font-family:system-ui;padding:40px'><h1>Enrollment denied</h1><p>You can close this tab.</p></body></html>")
110
+ elif not ticket:
111
+ holder["error"] = "missing ticket"
112
+ self.send_response(400)
113
+ self.send_header("Content-Type", "text/html")
114
+ self.end_headers()
115
+ self.wfile.write(b"<html><body style='font-family:system-ui;padding:40px'><h1>Missing ticket</h1></body></html>")
116
+ else:
117
+ holder["ticket"] = ticket
118
+ self.send_response(200)
119
+ self.send_header("Content-Type", "text/html")
120
+ self.end_headers()
121
+ self.wfile.write(b"<html><body style='font-family:system-ui;padding:40px;text-align:center'><h1>Approved</h1><p>You can close this tab and return to your terminal.</p></body></html>")
122
+ handler_event.set()
123
+
124
+ def log_message(self, *_a, **_kw):
125
+ return
126
+
127
+ server = socketserver.TCPServer(("127.0.0.1", 0), Handler)
128
+ port = server.server_address[1]
129
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
130
+ thread.start()
131
+
132
+ try:
133
+ callback = f"http://127.0.0.1:{port}/cb"
134
+ dash = _dashboard_url(api_base_url)
135
+ url = f"{dash}/sdk-bootstrap?" + urllib.parse.urlencode({
136
+ "agent_id": agent_id,
137
+ "callback": callback,
138
+ "state": state,
139
+ "machine": machine,
140
+ "process": proc_label,
141
+ })
142
+
143
+ sys.stderr.write("\n STACK SDK agent enrollment\n")
144
+ sys.stderr.write(" ──────────────────────────\n\n")
145
+ sys.stderr.write(f" Approve in your browser: {url}\n\n")
146
+
147
+ try:
148
+ webbrowser.open(url)
149
+ except Exception:
150
+ pass
151
+
152
+ if not handler_event.wait(timeout=ENROLLMENT_AUTH_TIMEOUT_S):
153
+ raise RuntimeError("enrollment approval timed out (5 min). Re-run to try again.")
154
+ if holder["error"]:
155
+ raise RuntimeError(f"enrollment {holder['error']}")
156
+ ticket = holder["ticket"]
157
+ if not isinstance(ticket, str):
158
+ raise RuntimeError("enrollment callback produced no ticket")
159
+ return ticket
160
+ finally:
161
+ server.shutdown()
162
+ server.server_close()
163
+
164
+
165
+ def _b64url_no_pad(data: bytes) -> str:
166
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
167
+
168
+
169
+ def _key_path(agent_id: str) -> Path:
170
+ return Path(os.path.expanduser("~")) / ".stack" / "agents" / f"{agent_id}.json"
171
+
172
+
173
+ def _load_stored(agent_id: str) -> dict | None:
174
+ path = _key_path(agent_id)
175
+ if not path.exists():
176
+ return None
177
+ try:
178
+ return json.loads(path.read_text(encoding="utf8"))
179
+ except (OSError, json.JSONDecodeError):
180
+ return None
181
+
182
+
183
+ def _persist_stored(agent_id: str, payload: dict) -> None:
184
+ path = _key_path(agent_id)
185
+ path.parent.mkdir(parents=True, exist_ok=True)
186
+ path.write_text(json.dumps(payload, indent=2), encoding="utf8")
187
+ try:
188
+ os.chmod(path, 0o600)
189
+ except OSError:
190
+ # Windows / unsupported FS — ignore.
191
+ pass
192
+
193
+
194
+ def _generate_keypair() -> tuple[Ed25519PrivateKey, dict, dict]:
195
+ """Generate an Ed25519 keypair and return (privkey_obj, public_jwk,
196
+ private_jwk_with_d)."""
197
+ priv = Ed25519PrivateKey.generate()
198
+ pub = priv.public_key()
199
+ pub_raw = pub.public_bytes(Encoding.Raw, PublicFormat.Raw)
200
+ priv_raw = priv.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
201
+ public_jwk = {"kty": "OKP", "crv": "Ed25519", "x": _b64url_no_pad(pub_raw)}
202
+ private_jwk = {**public_jwk, "d": _b64url_no_pad(priv_raw)}
203
+ return priv, public_jwk, private_jwk
204
+
205
+
206
+ def _privkey_from_jwk(jwk: dict) -> Ed25519PrivateKey:
207
+ pad = "=" * (-len(jwk["d"]) % 4)
208
+ raw = base64.urlsafe_b64decode(jwk["d"] + pad)
209
+ return Ed25519PrivateKey.from_private_bytes(raw)
210
+
211
+
212
+ def _enroll(
213
+ agent_id: str,
214
+ base_url: str,
215
+ bearer_provider: Callable[[], str],
216
+ ) -> dict:
217
+ """Run the proof-of-possession enrollment dance. Returns the stored
218
+ payload (with private JWK) ready to persist.
219
+
220
+ Two paths converge on the same /enrollment-challenge + /enroll calls:
221
+
222
+ - Interactive: open the dashboard /sdk-bootstrap page in the user's
223
+ browser, get a single-use enrollment ticket, use it as the bearer.
224
+ - Headless: call ``bearer_provider`` (developer's OAuth or
225
+ STACK_API_KEY) directly. No browser, no human in the loop.
226
+ """
227
+ if should_run_browser_bootstrap():
228
+ try:
229
+ bearer = _ticket_via_browser_approval(agent_id, base_url)
230
+ except RuntimeError as e:
231
+ raise RuntimeError(
232
+ f"Browser approval for SDK agent enrollment failed ({e}). "
233
+ f"Either fix the issue and re-run, or set STACK_API_KEY "
234
+ f"(or STACK_AUTH_INTERACTIVE=false) to use the silent "
235
+ f"enrollment path."
236
+ ) from e
237
+ else:
238
+ bearer = bearer_provider()
239
+ base = base_url.rstrip("/")
240
+
241
+ # Step 1 — request challenge.
242
+ r = httpx.post(
243
+ f"{base}/v1/agents/{agent_id}/enrollment-challenge",
244
+ headers={"Authorization": f"Bearer {bearer}", "Content-Type": "application/json"},
245
+ timeout=15.0,
246
+ )
247
+ r.raise_for_status()
248
+ challenge_body = r.json()
249
+ challenge: str = challenge_body["challenge"]
250
+ challenge_id: str = challenge_body["challenge_id"]
251
+
252
+ # Step 2 — generate keypair locally.
253
+ priv_obj, public_jwk, private_jwk = _generate_keypair()
254
+
255
+ # Step 3 — sign the challenge bytes.
256
+ sig = priv_obj.sign(challenge.encode("utf8"))
257
+ signed_challenge = _b64url_no_pad(sig)
258
+
259
+ # Step 4 — POST /enroll.
260
+ r2 = httpx.post(
261
+ f"{base}/v1/agents/{agent_id}/enroll",
262
+ headers={"Authorization": f"Bearer {bearer}", "Content-Type": "application/json"},
263
+ json={
264
+ "public_key": public_jwk,
265
+ "challenge_id": challenge_id,
266
+ "signed_challenge": signed_challenge,
267
+ },
268
+ timeout=15.0,
269
+ )
270
+ r2.raise_for_status()
271
+ enrolled = r2.json()
272
+
273
+ return {
274
+ "agent_id": agent_id,
275
+ "publicKey": public_jwk,
276
+ "privateKey": private_jwk,
277
+ "enrolled_at": enrolled.get("enrolled_at"),
278
+ }
279
+
280
+
281
+ def _sign_agent_jwt(stored: dict, agent_id: str) -> str:
282
+ """Mint a fresh 60-second EdDSA JWT signed with the agent privkey."""
283
+ priv_obj = _privkey_from_jwk(stored["privateKey"])
284
+ now = int(time.time())
285
+ payload = {
286
+ "iss": AGENT_JWT_ISSUER,
287
+ "sub": agent_id,
288
+ "aud": AGENT_JWT_AUDIENCE,
289
+ "iat": now,
290
+ "nbf": now,
291
+ "exp": now + AGENT_JWT_TTL,
292
+ "jti": f"aj_{now}_{os.urandom(6).hex()}",
293
+ }
294
+ # PyJWT accepts a raw cryptography Ed25519 key when algorithm='EdDSA'.
295
+ return jwt.encode(payload, priv_obj, algorithm="EdDSA")
296
+
297
+
298
+ class AgentKeypairAuth(AuthStrategy):
299
+ """Phase 2 agent-runtime auth strategy.
300
+
301
+ Constructed with an agent_id and a fallback bearer provider (used
302
+ only for the one-time enrollment dance). Once enrolled, every
303
+ request mints a fresh 60-second JWT signed by the locally-stored
304
+ privkey — the bearer provider is no longer consulted.
305
+ """
306
+
307
+ def __init__(
308
+ self,
309
+ agent_id: str,
310
+ api_base_url: str,
311
+ bearer_provider: Callable[[], str],
312
+ ):
313
+ self.agent_id = agent_id
314
+ self.api_base_url = api_base_url
315
+ self.bearer_provider = bearer_provider
316
+ self._stored: dict | None = None
317
+
318
+ def _ensure_stored(self) -> dict:
319
+ if self._stored is not None:
320
+ return self._stored
321
+ stored = _load_stored(self.agent_id)
322
+ if stored is None:
323
+ stored = _enroll(self.agent_id, self.api_base_url, self.bearer_provider)
324
+ _persist_stored(self.agent_id, stored)
325
+ self._stored = stored
326
+ return stored
327
+
328
+ def get_headers(self) -> dict[str, str]:
329
+ stored = self._ensure_stored()
330
+ token = _sign_agent_jwt(stored, self.agent_id)
331
+ return {"Authorization": f"Bearer {token}"}
@@ -0,0 +1,212 @@
1
+ """Phase 1 SDK first-run browser-spawn bootstrap (Python).
2
+
3
+ Mirror of packages/sdk/src/browser-bootstrap.ts. When ``Stack()`` is
4
+ constructed with no api_key, no STACK_API_KEY env, and no
5
+ ~/.stack/credentials.json — instead of raising, the SDK can spawn the
6
+ user's browser and run the OAuth Authorization Code + PKCE dance
7
+ itself.
8
+
9
+ Triggered ONLY when the process is interactive (TTY + not CI). In
10
+ production agent runtimes (Lambda, Vercel, K8s) the SDK falls through
11
+ to the standard "no credentials" error and the operator must set
12
+ STACK_API_KEY (or use agent_id mode).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import hashlib
19
+ import http.server
20
+ import json
21
+ import os
22
+ import secrets
23
+ import socket
24
+ import socketserver
25
+ import sys
26
+ import threading
27
+ import time
28
+ import urllib.parse
29
+ import webbrowser
30
+ from pathlib import Path
31
+
32
+ import httpx
33
+
34
+ DEFAULT_AUTH_TIMEOUT_S = 5 * 60 # matches the auth-code TTL
35
+
36
+
37
+ def _credentials_path() -> Path:
38
+ return Path(os.path.expanduser("~")) / ".stack" / "credentials.json"
39
+
40
+
41
+ def _b64url_no_pad(data: bytes) -> str:
42
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
43
+
44
+
45
+ def should_run_browser_bootstrap() -> bool:
46
+ """Conservative gate: only spawn if we're at an interactive terminal."""
47
+ if not sys.stdout.isatty():
48
+ return False
49
+ if os.environ.get("STACK_AUTH_INTERACTIVE", "").lower() == "false":
50
+ return False
51
+ if os.environ.get("CI"):
52
+ return False
53
+ return True
54
+
55
+
56
+ def browser_bootstrap(base_url: str) -> dict:
57
+ """Run the full OAuth Authorization-Code + PKCE flow.
58
+
59
+ Returns ``{"access_token", "refresh_token", "expires_in", "client_id"}``.
60
+ Raises RuntimeError on any step failure.
61
+ """
62
+ base = base_url.rstrip("/")
63
+
64
+ # 1. DCR a public PKCE client. We re-register with the loopback URI
65
+ # once we know the bound port (DCR is open; the second register
66
+ # supersedes for redirect_uri matching).
67
+ name = f"STACK Python SDK on {socket.gethostname()[:40]}"
68
+
69
+ # 2. Pick a free port + spin up the callback server first, so we can
70
+ # register the client with the actual loopback URI.
71
+ code_holder: dict = {"code": None, "error": None, "expected_state": None}
72
+ handler_event = threading.Event()
73
+
74
+ class Handler(http.server.BaseHTTPRequestHandler):
75
+ def do_GET(self): # noqa: N802
76
+ parsed = urllib.parse.urlparse(self.path)
77
+ if parsed.path != "/callback":
78
+ self.send_response(404)
79
+ self.end_headers()
80
+ return
81
+ params = urllib.parse.parse_qs(parsed.query)
82
+ err = params.get("error", [None])[0]
83
+ code = params.get("code", [None])[0]
84
+ state = params.get("state", [None])[0]
85
+ if err:
86
+ code_holder["error"] = err
87
+ self.send_response(400)
88
+ self.send_header("Content-Type", "text/html")
89
+ self.end_headers()
90
+ self.wfile.write(b"<html><body style='font-family:system-ui;padding:40px'><h1>Sign-in failed</h1><p>You can close this tab.</p></body></html>")
91
+ elif not code or state != code_holder["expected_state"]:
92
+ code_holder["error"] = "invalid callback (state mismatch or missing code)"
93
+ self.send_response(400)
94
+ self.send_header("Content-Type", "text/html")
95
+ self.end_headers()
96
+ self.wfile.write(b"<html><body style='font-family:system-ui;padding:40px'><h1>Invalid callback</h1></body></html>")
97
+ else:
98
+ code_holder["code"] = code
99
+ self.send_response(200)
100
+ self.send_header("Content-Type", "text/html")
101
+ self.end_headers()
102
+ self.wfile.write(b"<html><body style='font-family:system-ui;padding:40px;text-align:center'><h1>Signed in</h1><p>You can close this tab and return to your terminal.</p></body></html>")
103
+ handler_event.set()
104
+
105
+ def log_message(self, *_args, **_kwargs):
106
+ return # silence access logs
107
+
108
+ # Bind to port 0 to get a free port.
109
+ server = socketserver.TCPServer(("127.0.0.1", 0), Handler)
110
+ port = server.server_address[1]
111
+ server_thread = threading.Thread(target=server.serve_forever, daemon=True)
112
+ server_thread.start()
113
+
114
+ try:
115
+ redirect_uri = f"http://127.0.0.1:{port}/callback"
116
+
117
+ # DCR with the actual loopback URI.
118
+ r = httpx.post(
119
+ f"{base}/oauth/register",
120
+ json={
121
+ "client_name": name,
122
+ "redirect_uris": [redirect_uri],
123
+ "grant_types": ["authorization_code", "refresh_token"],
124
+ "software_id": "getstackrun.sdk-python.bootstrap",
125
+ },
126
+ timeout=15.0,
127
+ )
128
+ if r.status_code != 201:
129
+ raise RuntimeError(
130
+ f"STACK SDK: client registration failed ({r.status_code}). "
131
+ f"Try setting STACK_API_KEY or running `stack-cli auth login`."
132
+ )
133
+ client_id = r.json()["client_id"]
134
+
135
+ # 3. PKCE pair + state.
136
+ verifier = _b64url_no_pad(secrets.token_bytes(32))
137
+ challenge = _b64url_no_pad(hashlib.sha256(verifier.encode()).digest())
138
+ state = _b64url_no_pad(secrets.token_bytes(16))
139
+ code_holder["expected_state"] = state
140
+
141
+ scope = "passports:read passports:write agents:read agents:write services:read services:connect credentials:read proxy:read proxy:write audit:read"
142
+ authorize_url = f"{base}/oauth/authorize?" + urllib.parse.urlencode({
143
+ "response_type": "code",
144
+ "client_id": client_id,
145
+ "redirect_uri": redirect_uri,
146
+ "scope": scope,
147
+ "resource": "https://api.getstack.run",
148
+ "code_challenge": challenge,
149
+ "code_challenge_method": "S256",
150
+ "state": state,
151
+ })
152
+
153
+ sys.stderr.write("\n STACK SDK first-run sign-in\n")
154
+ sys.stderr.write(" ──────────────────────────\n\n")
155
+ sys.stderr.write(" Opening your browser to approve.\n")
156
+ sys.stderr.write(f" If it doesn't open, paste this URL:\n\n {authorize_url}\n\n")
157
+
158
+ try:
159
+ webbrowser.open(authorize_url)
160
+ except Exception:
161
+ pass # best-effort
162
+
163
+ # 4. Wait for the callback.
164
+ if not handler_event.wait(timeout=DEFAULT_AUTH_TIMEOUT_S):
165
+ raise RuntimeError("Sign-in timed out (5 min). Re-run to try again.")
166
+ if code_holder["error"]:
167
+ raise RuntimeError(f"OAuth error: {code_holder['error']}")
168
+ code = code_holder["code"]
169
+
170
+ # 5. Exchange code for tokens.
171
+ r2 = httpx.post(
172
+ f"{base}/oauth/token",
173
+ data={
174
+ "grant_type": "authorization_code",
175
+ "code": code,
176
+ "redirect_uri": redirect_uri,
177
+ "client_id": client_id,
178
+ "code_verifier": verifier,
179
+ },
180
+ timeout=15.0,
181
+ )
182
+ if r2.status_code != 200:
183
+ raise RuntimeError(f"STACK SDK: token exchange failed ({r2.status_code}) {r2.text}")
184
+ tokens = r2.json()
185
+
186
+ # 6. Persist for subsequent runs. Same file the CLI + the TS SDK write.
187
+ path = _credentials_path()
188
+ path.parent.mkdir(parents=True, exist_ok=True)
189
+ stored = {
190
+ "client_id": client_id,
191
+ "refresh_token": tokens["refresh_token"],
192
+ "issued_at": int(time.time()),
193
+ "refresh_expires_at": int(time.time()) + 30 * 24 * 60 * 60,
194
+ "scope": scope,
195
+ }
196
+ path.write_text(json.dumps(stored, indent=2), encoding="utf8")
197
+ try:
198
+ os.chmod(path, 0o600)
199
+ except OSError:
200
+ pass
201
+
202
+ sys.stderr.write(f" Signed in. Credentials saved to {path}\n\n")
203
+
204
+ return {
205
+ "access_token": tokens["access_token"],
206
+ "refresh_token": tokens["refresh_token"],
207
+ "expires_in": tokens.get("expires_in", 300),
208
+ "client_id": client_id,
209
+ }
210
+ finally:
211
+ server.shutdown()
212
+ server.server_close()
@@ -0,0 +1,196 @@
1
+ """Intent simulation + pre-execution approval gate.
2
+
3
+ Phase 2.5a Macro 2 Sub-chunks 2.1 + 2.2.
4
+
5
+ simulate() Dry-run a candidate IntentClaim against a passport.
6
+ Returns a signed simulation result.
7
+ submit() Register the Intent + run simulation + persist a
8
+ pending intent_approvals row. Returns an approval id;
9
+ operators decide via approve() / reject().
10
+ submit_and_wait()
11
+ Submit then poll until the approval reaches a terminal
12
+ state or the timeout elapses.
13
+ list_pending()
14
+ Operator-scope queue of pending approvals.
15
+ approve() / reject()
16
+ Operator transitions a pending row to approved or
17
+ rejected. Rejection auto-revokes the passport;
18
+ block_future=True additionally suspends the agent.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import time
24
+ from typing import Any
25
+
26
+ from .client import HttpClient
27
+
28
+
29
+ class IntentsService:
30
+ def __init__(self, client: HttpClient):
31
+ self._client = client
32
+
33
+ # ─── 2.1 — simulate ─────────────────────────────────────────────
34
+
35
+ def simulate(
36
+ self,
37
+ intent: dict[str, Any],
38
+ passport_token: str,
39
+ *,
40
+ intent_claim_ref: str | None = None,
41
+ persist: bool | None = None,
42
+ ) -> dict[str, Any]:
43
+ """Dry-run an Intent. No approval row created.
44
+
45
+ Args:
46
+ intent: IntentClaim payload (type, intent_type, agent_id,
47
+ named_intent, target, action, parameters, estimated_cost,
48
+ accountability, reason, requires, user_subject,
49
+ mission_ref, submitted_at).
50
+ passport_token: Passport JWT to simulate against.
51
+ intent_claim_ref: Optional pc_* claim id to link this
52
+ simulation to a pre-registered Intent.
53
+ persist: When False, skips writing the simulation claim.
54
+ Default True.
55
+
56
+ Returns:
57
+ Dict with allowed, denial_reasons, simulated_cost,
58
+ predicted_detector_fires, simulated_at, claim_id?,
59
+ diagnostics.
60
+ """
61
+ body: dict[str, Any] = {"intent": intent}
62
+ if intent_claim_ref is not None:
63
+ body["intent_claim_ref"] = intent_claim_ref
64
+ if persist is not None:
65
+ body["persist"] = persist
66
+ return self._client.request(
67
+ "POST",
68
+ "/v1/intents/simulate",
69
+ json=body,
70
+ extra_headers={"X-Passport-Token": passport_token},
71
+ )
72
+
73
+ # ─── 2.2 — submit / approval flow ───────────────────────────────
74
+
75
+ def submit(
76
+ self,
77
+ intent: dict[str, Any],
78
+ passport_token: str,
79
+ *,
80
+ expires_in_seconds: int | None = None,
81
+ skip_simulation: bool = False,
82
+ ) -> dict[str, Any]:
83
+ """Submit an Intent for pre-execution operator approval.
84
+
85
+ Returns:
86
+ Dict with approval_id, intent_claim_id, simulated_claim_id?,
87
+ status='pending', expires_at, simulation? (when not skipped).
88
+ """
89
+ body: dict[str, Any] = {"intent": intent}
90
+ if expires_in_seconds is not None:
91
+ body["expires_in_seconds"] = expires_in_seconds
92
+ if skip_simulation:
93
+ body["skip_simulation"] = True
94
+ return self._client.request(
95
+ "POST",
96
+ "/v1/intents/submit",
97
+ json=body,
98
+ extra_headers={"X-Passport-Token": passport_token},
99
+ )
100
+
101
+ def submit_and_wait(
102
+ self,
103
+ intent: dict[str, Any],
104
+ passport_token: str,
105
+ *,
106
+ expires_in_seconds: int | None = None,
107
+ skip_simulation: bool = False,
108
+ timeout_seconds: float = 60.0,
109
+ poll_interval_seconds: float = 1.0,
110
+ ) -> dict[str, Any]:
111
+ """Submit then poll the pending list until the row leaves
112
+ 'pending' or the timeout elapses.
113
+
114
+ Returns:
115
+ Dict with keys 'initial' (the submit response),
116
+ 'final' (the last known row state), and 'timed_out' (bool).
117
+ When the row disappears from the pending list before timeout,
118
+ it has been decided; 'timed_out' is False.
119
+ """
120
+ initial = self.submit(
121
+ intent,
122
+ passport_token,
123
+ expires_in_seconds=expires_in_seconds,
124
+ skip_simulation=skip_simulation,
125
+ )
126
+ approval_id = initial["approval_id"]
127
+ deadline = time.monotonic() + timeout_seconds
128
+ last_row: dict[str, Any] | None = None
129
+ while time.monotonic() < deadline:
130
+ response = self.list_pending()
131
+ items = response.get("items", [])
132
+ match = next((r for r in items if r.get("id") == approval_id), None)
133
+ if match is None:
134
+ # Row left the pending queue → terminal state.
135
+ final = last_row or {"id": approval_id, "status": "approved"}
136
+ return {"initial": initial, "final": final, "timed_out": False}
137
+ last_row = match
138
+ time.sleep(poll_interval_seconds)
139
+ return {
140
+ "initial": initial,
141
+ "final": last_row or {"id": approval_id, "status": "pending"},
142
+ "timed_out": True,
143
+ }
144
+
145
+ def list_pending(
146
+ self,
147
+ *,
148
+ page: int | None = None,
149
+ limit: int | None = None,
150
+ ) -> dict[str, Any]:
151
+ """List pending approvals scoped to the calling operator."""
152
+ params: dict[str, Any] = {}
153
+ if page is not None:
154
+ params["page"] = page
155
+ if limit is not None:
156
+ params["limit"] = limit
157
+ return self._client.request("GET", "/v1/intents/pending", params=params or None)
158
+
159
+ def approve(
160
+ self,
161
+ approval_id: str,
162
+ *,
163
+ notes: str | None = None,
164
+ ) -> dict[str, Any]:
165
+ """Transition a pending approval → approved."""
166
+ body: dict[str, Any] = {}
167
+ if notes is not None:
168
+ body["notes"] = notes
169
+ return self._client.request(
170
+ "POST",
171
+ f"/v1/intents/{approval_id}/approve",
172
+ json=body,
173
+ )
174
+
175
+ def reject(
176
+ self,
177
+ approval_id: str,
178
+ *,
179
+ notes: str | None = None,
180
+ block_future: bool = False,
181
+ ) -> dict[str, Any]:
182
+ """Transition a pending approval → rejected.
183
+
184
+ Auto-revokes the passport. block_future=True additionally
185
+ suspends the agent so subsequent passports cannot be issued.
186
+ """
187
+ body: dict[str, Any] = {}
188
+ if notes is not None:
189
+ body["notes"] = notes
190
+ if block_future:
191
+ body["block_future"] = True
192
+ return self._client.request(
193
+ "POST",
194
+ f"/v1/intents/{approval_id}/reject",
195
+ json=body,
196
+ )
@@ -1,189 +0,0 @@
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}"}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes