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.
- getstack-0.3.0/.gitignore +61 -0
- getstack-0.3.0/CLAUDE.md +46 -0
- {getstack-0.1.0 → getstack-0.3.0}/PKG-INFO +36 -16
- {getstack-0.1.0 → getstack-0.3.0}/README.md +33 -15
- {getstack-0.1.0 → getstack-0.3.0}/pyproject.toml +9 -1
- {getstack-0.1.0 → getstack-0.3.0}/src/getstack/__init__.py +99 -12
- getstack-0.3.0/src/getstack/agent_auth.py +189 -0
- getstack-0.3.0/src/getstack/audit.py +56 -0
- getstack-0.3.0/src/getstack/auth.py +157 -0
- {getstack-0.1.0 → getstack-0.3.0}/src/getstack/client.py +4 -3
- getstack-0.3.0/src/getstack/credentials.py +44 -0
- getstack-0.3.0/src/getstack/identity.py +119 -0
- {getstack-0.1.0 → getstack-0.3.0}/src/getstack/notifications.py +10 -5
- {getstack-0.1.0 → getstack-0.3.0}/src/getstack/passports.py +44 -0
- getstack-0.3.0/src/getstack/proxy.py +109 -0
- getstack-0.3.0/src/getstack/scan.py +77 -0
- getstack-0.3.0/src/getstack/security_events.py +27 -0
- getstack-0.3.0/src/getstack/skills.py +199 -0
- {getstack-0.1.0 → getstack-0.3.0}/src/getstack/types.py +88 -0
- getstack-0.1.0/.gitignore +0 -38
- getstack-0.1.0/src/getstack/audit.py +0 -26
- getstack-0.1.0/src/getstack/auth.py +0 -78
- getstack-0.1.0/src/getstack/credentials.py +0 -33
- {getstack-0.1.0 → getstack-0.3.0}/LICENSE +0 -0
- {getstack-0.1.0 → getstack-0.3.0}/src/getstack/agents.py +0 -0
- {getstack-0.1.0 → getstack-0.3.0}/src/getstack/dropoffs.py +0 -0
- {getstack-0.1.0 → getstack-0.3.0}/src/getstack/errors.py +0 -0
- {getstack-0.1.0 → getstack-0.3.0}/src/getstack/py.typed +0 -0
- {getstack-0.1.0 → getstack-0.3.0}/src/getstack/reviews.py +0 -0
- {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
|
getstack-0.3.0/CLAUDE.md
ADDED
|
@@ -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.
|
|
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
|
|
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
|
-
#
|
|
46
|
-
|
|
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
|
-
#
|
|
62
|
-
stack = Stack(
|
|
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
|
-
#
|
|
65
|
-
stack = Stack
|
|
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
|
-
#
|
|
73
|
-
stack = Stack
|
|
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
|
|
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
|
-
#
|
|
22
|
-
|
|
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
|
-
#
|
|
38
|
-
stack = Stack(
|
|
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
|
-
#
|
|
41
|
-
stack = Stack
|
|
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
|
-
#
|
|
49
|
-
stack = Stack
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
119
|
-
|
|
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
|
-
|
|
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
|
|
194
|
-
|
|
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
|
-
|
|
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}"}
|