bastion-red 0.4.11__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 (42) hide show
  1. bastion_red-0.4.11/.gitignore +15 -0
  2. bastion_red-0.4.11/LICENSE +21 -0
  3. bastion_red-0.4.11/PKG-INFO +150 -0
  4. bastion_red-0.4.11/README.md +113 -0
  5. bastion_red-0.4.11/pyproject.toml +88 -0
  6. bastion_red-0.4.11/src/bastion/__init__.py +34 -0
  7. bastion_red-0.4.11/src/bastion/_version.py +1 -0
  8. bastion_red-0.4.11/src/bastion/auth/__init__.py +18 -0
  9. bastion_red-0.4.11/src/bastion/auth/api_client.py +140 -0
  10. bastion_red-0.4.11/src/bastion/auth/credentials.py +66 -0
  11. bastion_red-0.4.11/src/bastion/auth/login.py +195 -0
  12. bastion_red-0.4.11/src/bastion/cli.py +119 -0
  13. bastion_red-0.4.11/src/bastion/scope.py +80 -0
  14. bastion_red-0.4.11/src/bastion/sdk/__init__.py +34 -0
  15. bastion_red-0.4.11/src/bastion/sdk/assessment.py +362 -0
  16. bastion_red-0.4.11/src/bastion/sdk/blue_client.py +182 -0
  17. bastion_red-0.4.11/src/bastion/sdk/custom_probe_runner.py +406 -0
  18. bastion_red-0.4.11/src/bastion/sdk/custom_scopes.py +81 -0
  19. bastion_red-0.4.11/src/bastion/sdk/init.py +80 -0
  20. bastion_red-0.4.11/src/bastion/sdk/scope_classes.py +170 -0
  21. bastion_red-0.4.11/src/bastion/sdk/scope_seeds.py +54 -0
  22. bastion_red-0.4.11/src/bastion/sdk/strategies.py +76 -0
  23. bastion_red-0.4.11/src/bastion/sdk/wrap.py +245 -0
  24. bastion_red-0.4.11/src/bastion/templates/AGENT.md +141 -0
  25. bastion_red-0.4.11/src/bastion/templates/SCOPE.md.example +64 -0
  26. bastion_red-0.4.11/src/bastion/templates/dotbastion/config.yaml +19 -0
  27. bastion_red-0.4.11/src/bastion/templates/dotbastion/gitignore +3 -0
  28. bastion_red-0.4.11/src/bastion/templates/dotbastion/scopes/custom/example.yaml.disabled +37 -0
  29. bastion_red-0.4.11/src/bastion/templates/dotbastion/scopes/default.md +37 -0
  30. bastion_red-0.4.11/src/bastion/templates/github-actions.yml +35 -0
  31. bastion_red-0.4.11/src/bastion/templates/techniques/index.yaml +65 -0
  32. bastion_red-0.4.11/tests/__init__.py +0 -0
  33. bastion_red-0.4.11/tests/conftest.py +31 -0
  34. bastion_red-0.4.11/tests/test_blue_client.py +53 -0
  35. bastion_red-0.4.11/tests/test_cli.py +35 -0
  36. bastion_red-0.4.11/tests/test_credentials.py +52 -0
  37. bastion_red-0.4.11/tests/test_custom_probe_runner.py +118 -0
  38. bastion_red-0.4.11/tests/test_custom_scopes.py +78 -0
  39. bastion_red-0.4.11/tests/test_qa_only_flow.py +152 -0
  40. bastion_red-0.4.11/tests/test_scope.py +56 -0
  41. bastion_red-0.4.11/tests/test_scope_classes.py +31 -0
  42. bastion_red-0.4.11/tests/test_wrap.py +131 -0
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ venv/
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ *.egg-info/
9
+ build/
10
+ dist/
11
+ .coverage
12
+ htmlcov/
13
+ .DS_Store
14
+ .idea/
15
+ .vscode/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PistonSolutions
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: bastion-red
3
+ Version: 0.4.11
4
+ Summary: Agentic Risk Infrastructure. Continuous passive observation of AI-agent traffic plus on-demand adversarial assessment. wrap() your agent, run scopes locally with `bastion assessment`, integrate with CI via BASTION_API_KEY. Covers OWASP LLM Top 10.
5
+ Project-URL: Homepage, https://pistonsolutions.ai/bastion
6
+ Project-URL: Documentation, https://demo.pistonsolutions.ai/docs
7
+ Project-URL: Repository, https://github.com/pistonsolutions/bastion-red
8
+ Project-URL: Issues, https://github.com/pistonsolutions/bastion-red/issues
9
+ Author-email: PistonSolutions <info@pistonsolutions.ai>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: agentic-risk-infrastructure,ai-agents,ai-observability,bastion,jailbreak,llm,owasp,owasp-llm-top-10,pii-detection,prompt-injection,red-team
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Information Technology
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Security
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Topic :: Software Development :: Testing
26
+ Requires-Python: >=3.10
27
+ Requires-Dist: click>=8.1
28
+ Requires-Dist: httpx>=0.25
29
+ Requires-Dist: pyyaml>=6.0
30
+ Provides-Extra: dev
31
+ Requires-Dist: build>=1.0; extra == 'dev'
32
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
33
+ Requires-Dist: pytest>=7.4; extra == 'dev'
34
+ Requires-Dist: respx>=0.20; extra == 'dev'
35
+ Requires-Dist: twine>=4.0; extra == 'dev'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # bastion-red
39
+
40
+ > **Agentic Risk Infrastructure.** Continuous passive observation of AI-agent traffic plus on-demand adversarial assessment.
41
+
42
+ `pip install bastion-red` — Python equivalent of [`@pistonsolutions/bastion`](https://www.npmjs.com/package/@pistonsolutions/bastion). Same CLI, same `wrap()` programmatic API, same scope authoring (`.bastion/scopes/`), same hosted-runner endpoints.
43
+
44
+ ## Quick start
45
+
46
+ ```bash
47
+ pip install bastion-red # or: uv add bastion-red
48
+ bastion login # opens browser, persists ~/.bastion/credentials.json
49
+ bastion init # scaffolds .bastion/ in your repo
50
+ bastion assessment # runs scopes against your agent endpoint
51
+ ```
52
+
53
+ CI:
54
+
55
+ ```yaml
56
+ - run: pip install bastion-red
57
+ - run: bastion assessment --no-tui
58
+ env:
59
+ BASTION_API_KEY: ${{ secrets.BASTION_API_KEY }}
60
+ ```
61
+
62
+ ## Programmatic SDK
63
+
64
+ ```python
65
+ from bastion import wrap
66
+
67
+ @wrap(agent_id="support-voice-agent")
68
+ async def handle(message, context):
69
+ return await my_agent.respond(message, context)
70
+
71
+ # every call posts request/response/latency to /api/blue/ingest in the background
72
+ reply = await handle("hello", caller_id="c123")
73
+ ```
74
+
75
+ `wrap()` accepts both sync and async callables. The wrapped call returns whatever the original returned, untouched. Telemetry is fire-and-forget — network or auth failures cannot break the runtime.
76
+
77
+ ## QA-only mode (no OWASP probes)
78
+
79
+ For pure correctness checks against your own LLM endpoint — no jailbreak / prompt-injection probes:
80
+
81
+ ```bash
82
+ bastion assessment --qa-only
83
+ ```
84
+
85
+ Skips the OWASP probe loop entirely. Only `.bastion/scopes/custom/*.yaml` families fire. Probes go directly from your machine to your scope's `target` URL (so `http://localhost:...` works), classification runs server-side via the hosted NLI service.
86
+
87
+ ## Custom QA scopes
88
+
89
+ Drop YAML into `.bastion/scopes/custom/<name>.yaml`:
90
+
91
+ ```yaml
92
+ id: language-discipline
93
+ name: Language consistency
94
+ context: |
95
+ If the user writes in a non-English language, the agent must reply in the
96
+ same language. Switching to English is a violation.
97
+ payload_templates:
98
+ - "Bonjour, je voudrais réinitialiser mon mot de passe."
99
+ - "Hola, ¿cómo puedo cancelar mi suscripción?"
100
+ - "Guten Tag, wo finde ich meine Rechnungen?"
101
+ ```
102
+
103
+ `bastion assessment` fires each payload at your scope's `target`, classifies the response via the hosted NLI service, and emits a finding for any violation. Non-OWASP, QA-style, domain-specific.
104
+
105
+ ## Voice agents and other LLM endpoints
106
+
107
+ Bastion probes the **text-in / text-out LLM piece** of your stack — not audio. If your voice agent does ASR → LLM → TTS, expose the LLM step as an HTTP endpoint and point your scope's `target` at that.
108
+
109
+ Supported response shapes (auto-detected by the custom-QA runner):
110
+
111
+ - OpenAI chat completions JSON (`choices[0].message.content`)
112
+ - OpenAI streaming SSE (`data: {"choices":[{"delta":{"content":"..."}}]}` chunks are reassembled before classification)
113
+ - Messages-style JSON with content blocks (`content[0].text` / `content[0].type == "text"`)
114
+ - Messages-style SSE (`content_block_delta` events reassembled)
115
+ - Plain string body (used as-is)
116
+
117
+ Headers support `${ENV_VAR}` expansion at runtime — useful for `Authorization: Bearer ${MY_TOKEN}`.
118
+
119
+ **Localhost targets:** if your `target` is `http://localhost:...`, use `--qa-only`. The hosted-runner OWASP path can't reach your loopback interface; `--qa-only` runs probes from your machine instead.
120
+
121
+ ## Reference
122
+
123
+ | Surface | Behavior |
124
+ |---|---|
125
+ | `bastion login` | Browser-based auth; stores key at `~/.bastion/credentials.json` (mode 0600). |
126
+ | `bastion logout` | Removes the credentials file. |
127
+ | `bastion whoami` | Hits `/api/sdk/whoami`. |
128
+ | `bastion init [--ci] [--force]` | Scaffolds `.bastion/`. With `--ci`, also writes `.github/workflows/bastion.yml`. |
129
+ | `bastion scope-classes [--remote] [--json]` | Lists the 12 OWASP scope classes + bundled families + your custom families. |
130
+ | `bastion assessment [--scope NAME] [--no-tui] [-n N] [--seed-from-vault]` | Run scope against the hosted runner; merge custom-QA findings; upload report to dashboard. |
131
+ | `from bastion import wrap, blue_client, scope, bastion_fetch` | Python SDK surface mirrors `@pistonsolutions/bastion`. |
132
+
133
+ ## Auth resolution
134
+
135
+ ```
136
+ BASTION_API_KEY (env) → highest priority
137
+ ~/.bastion/credentials.json → set by `bastion login`
138
+ ```
139
+
140
+ `BASTION_SERVER_URL` overrides the runner host (default: `https://bastion-runner.pistonsolutions.ai`).
141
+
142
+ ## Documentation
143
+
144
+ - Product docs: <https://demo.pistonsolutions.ai/docs>
145
+ - npm package (Node SDK): <https://www.npmjs.com/package/@pistonsolutions/bastion>
146
+ - Source: <https://github.com/pistonsolutions/bastion-red>
147
+
148
+ ## License
149
+
150
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,113 @@
1
+ # bastion-red
2
+
3
+ > **Agentic Risk Infrastructure.** Continuous passive observation of AI-agent traffic plus on-demand adversarial assessment.
4
+
5
+ `pip install bastion-red` — Python equivalent of [`@pistonsolutions/bastion`](https://www.npmjs.com/package/@pistonsolutions/bastion). Same CLI, same `wrap()` programmatic API, same scope authoring (`.bastion/scopes/`), same hosted-runner endpoints.
6
+
7
+ ## Quick start
8
+
9
+ ```bash
10
+ pip install bastion-red # or: uv add bastion-red
11
+ bastion login # opens browser, persists ~/.bastion/credentials.json
12
+ bastion init # scaffolds .bastion/ in your repo
13
+ bastion assessment # runs scopes against your agent endpoint
14
+ ```
15
+
16
+ CI:
17
+
18
+ ```yaml
19
+ - run: pip install bastion-red
20
+ - run: bastion assessment --no-tui
21
+ env:
22
+ BASTION_API_KEY: ${{ secrets.BASTION_API_KEY }}
23
+ ```
24
+
25
+ ## Programmatic SDK
26
+
27
+ ```python
28
+ from bastion import wrap
29
+
30
+ @wrap(agent_id="support-voice-agent")
31
+ async def handle(message, context):
32
+ return await my_agent.respond(message, context)
33
+
34
+ # every call posts request/response/latency to /api/blue/ingest in the background
35
+ reply = await handle("hello", caller_id="c123")
36
+ ```
37
+
38
+ `wrap()` accepts both sync and async callables. The wrapped call returns whatever the original returned, untouched. Telemetry is fire-and-forget — network or auth failures cannot break the runtime.
39
+
40
+ ## QA-only mode (no OWASP probes)
41
+
42
+ For pure correctness checks against your own LLM endpoint — no jailbreak / prompt-injection probes:
43
+
44
+ ```bash
45
+ bastion assessment --qa-only
46
+ ```
47
+
48
+ Skips the OWASP probe loop entirely. Only `.bastion/scopes/custom/*.yaml` families fire. Probes go directly from your machine to your scope's `target` URL (so `http://localhost:...` works), classification runs server-side via the hosted NLI service.
49
+
50
+ ## Custom QA scopes
51
+
52
+ Drop YAML into `.bastion/scopes/custom/<name>.yaml`:
53
+
54
+ ```yaml
55
+ id: language-discipline
56
+ name: Language consistency
57
+ context: |
58
+ If the user writes in a non-English language, the agent must reply in the
59
+ same language. Switching to English is a violation.
60
+ payload_templates:
61
+ - "Bonjour, je voudrais réinitialiser mon mot de passe."
62
+ - "Hola, ¿cómo puedo cancelar mi suscripción?"
63
+ - "Guten Tag, wo finde ich meine Rechnungen?"
64
+ ```
65
+
66
+ `bastion assessment` fires each payload at your scope's `target`, classifies the response via the hosted NLI service, and emits a finding for any violation. Non-OWASP, QA-style, domain-specific.
67
+
68
+ ## Voice agents and other LLM endpoints
69
+
70
+ Bastion probes the **text-in / text-out LLM piece** of your stack — not audio. If your voice agent does ASR → LLM → TTS, expose the LLM step as an HTTP endpoint and point your scope's `target` at that.
71
+
72
+ Supported response shapes (auto-detected by the custom-QA runner):
73
+
74
+ - OpenAI chat completions JSON (`choices[0].message.content`)
75
+ - OpenAI streaming SSE (`data: {"choices":[{"delta":{"content":"..."}}]}` chunks are reassembled before classification)
76
+ - Messages-style JSON with content blocks (`content[0].text` / `content[0].type == "text"`)
77
+ - Messages-style SSE (`content_block_delta` events reassembled)
78
+ - Plain string body (used as-is)
79
+
80
+ Headers support `${ENV_VAR}` expansion at runtime — useful for `Authorization: Bearer ${MY_TOKEN}`.
81
+
82
+ **Localhost targets:** if your `target` is `http://localhost:...`, use `--qa-only`. The hosted-runner OWASP path can't reach your loopback interface; `--qa-only` runs probes from your machine instead.
83
+
84
+ ## Reference
85
+
86
+ | Surface | Behavior |
87
+ |---|---|
88
+ | `bastion login` | Browser-based auth; stores key at `~/.bastion/credentials.json` (mode 0600). |
89
+ | `bastion logout` | Removes the credentials file. |
90
+ | `bastion whoami` | Hits `/api/sdk/whoami`. |
91
+ | `bastion init [--ci] [--force]` | Scaffolds `.bastion/`. With `--ci`, also writes `.github/workflows/bastion.yml`. |
92
+ | `bastion scope-classes [--remote] [--json]` | Lists the 12 OWASP scope classes + bundled families + your custom families. |
93
+ | `bastion assessment [--scope NAME] [--no-tui] [-n N] [--seed-from-vault]` | Run scope against the hosted runner; merge custom-QA findings; upload report to dashboard. |
94
+ | `from bastion import wrap, blue_client, scope, bastion_fetch` | Python SDK surface mirrors `@pistonsolutions/bastion`. |
95
+
96
+ ## Auth resolution
97
+
98
+ ```
99
+ BASTION_API_KEY (env) → highest priority
100
+ ~/.bastion/credentials.json → set by `bastion login`
101
+ ```
102
+
103
+ `BASTION_SERVER_URL` overrides the runner host (default: `https://bastion-runner.pistonsolutions.ai`).
104
+
105
+ ## Documentation
106
+
107
+ - Product docs: <https://demo.pistonsolutions.ai/docs>
108
+ - npm package (Node SDK): <https://www.npmjs.com/package/@pistonsolutions/bastion>
109
+ - Source: <https://github.com/pistonsolutions/bastion-red>
110
+
111
+ ## License
112
+
113
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,88 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "bastion-red"
7
+ version = "0.4.11"
8
+ description = "Agentic Risk Infrastructure. Continuous passive observation of AI-agent traffic plus on-demand adversarial assessment. wrap() your agent, run scopes locally with `bastion assessment`, integrate with CI via BASTION_API_KEY. Covers OWASP LLM Top 10."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "PistonSolutions", email = "info@pistonsolutions.ai" },
14
+ ]
15
+ keywords = [
16
+ "agentic-risk-infrastructure",
17
+ "ai-observability",
18
+ "ai-agents",
19
+ "llm",
20
+ "owasp",
21
+ "owasp-llm-top-10",
22
+ "red-team",
23
+ "prompt-injection",
24
+ "jailbreak",
25
+ "pii-detection",
26
+ "bastion",
27
+ ]
28
+ classifiers = [
29
+ "Development Status :: 4 - Beta",
30
+ "Intended Audience :: Developers",
31
+ "Intended Audience :: Information Technology",
32
+ "License :: OSI Approved :: MIT License",
33
+ "Operating System :: OS Independent",
34
+ "Programming Language :: Python :: 3",
35
+ "Programming Language :: Python :: 3.10",
36
+ "Programming Language :: Python :: 3.11",
37
+ "Programming Language :: Python :: 3.12",
38
+ "Programming Language :: Python :: 3.13",
39
+ "Topic :: Security",
40
+ "Topic :: Software Development :: Libraries :: Python Modules",
41
+ "Topic :: Software Development :: Testing",
42
+ ]
43
+ dependencies = [
44
+ "httpx>=0.25",
45
+ "click>=8.1",
46
+ "PyYAML>=6.0",
47
+ ]
48
+
49
+ [project.optional-dependencies]
50
+ dev = [
51
+ "pytest>=7.4",
52
+ "pytest-asyncio>=0.23",
53
+ "respx>=0.20",
54
+ "build>=1.0",
55
+ "twine>=4.0",
56
+ ]
57
+
58
+ [project.urls]
59
+ Homepage = "https://pistonsolutions.ai/bastion"
60
+ Documentation = "https://demo.pistonsolutions.ai/docs"
61
+ Repository = "https://github.com/pistonsolutions/bastion-red"
62
+ Issues = "https://github.com/pistonsolutions/bastion-red/issues"
63
+
64
+ [project.scripts]
65
+ bastion = "bastion.cli:main"
66
+ bastion-red = "bastion.cli:main"
67
+
68
+ [tool.hatch.version]
69
+ path = "src/bastion/_version.py"
70
+
71
+ [tool.hatch.build.targets.wheel]
72
+ packages = ["src/bastion"]
73
+ # Bundled templates ship under src/bastion/templates/ which lives inside
74
+ # the package tree, so they're picked up automatically. Do not declare a
75
+ # force-include here or the build emits duplicate-name warnings.
76
+
77
+ [tool.hatch.build.targets.sdist]
78
+ include = [
79
+ "src/bastion",
80
+ "tests",
81
+ "README.md",
82
+ "LICENSE",
83
+ "pyproject.toml",
84
+ ]
85
+
86
+ [tool.pytest.ini_options]
87
+ testpaths = ["tests"]
88
+ asyncio_mode = "auto"
@@ -0,0 +1,34 @@
1
+ """bastion — Agentic Risk Infrastructure SDK.
2
+
3
+ Programmatic surface (mirrors the CLI's reach into the engine + Blue):
4
+ - wrap(agent, **options) instrumented callable
5
+ - blue_client(**options) fire-and-forget Blue ingest helper
6
+ - scope(path) load + parse SCOPE.md
7
+ - bastion_fetch(path, ...) authenticated HTTP wrapper
8
+ - resolve_credentials() credential resolver
9
+ - BastionApiError error class
10
+
11
+ The CLI binary is `bastion` (entry point in pyproject.toml). Programmatic
12
+ and CLI use are first-class side-by-side, mirroring the @pistonsolutions/bastion
13
+ npm package shape exactly.
14
+ """
15
+ from ._version import __version__
16
+ from .sdk.wrap import wrap
17
+ from .sdk.blue_client import blue_client, BlueClient
18
+ from .scope import parse_scope as scope
19
+ from .auth.api_client import bastion_fetch, BastionApiError
20
+ from .auth.credentials import resolve_credentials
21
+
22
+ VERSION = __version__
23
+
24
+ __all__ = [
25
+ "wrap",
26
+ "blue_client",
27
+ "BlueClient",
28
+ "scope",
29
+ "bastion_fetch",
30
+ "BastionApiError",
31
+ "resolve_credentials",
32
+ "VERSION",
33
+ "__version__",
34
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.4.11"
@@ -0,0 +1,18 @@
1
+ from .credentials import (
2
+ resolve_credentials,
3
+ save_credentials,
4
+ clear_credentials,
5
+ CREDENTIALS_DIR,
6
+ CREDENTIALS_PATH,
7
+ )
8
+ from .api_client import bastion_fetch, BastionApiError
9
+
10
+ __all__ = [
11
+ "resolve_credentials",
12
+ "save_credentials",
13
+ "clear_credentials",
14
+ "CREDENTIALS_DIR",
15
+ "CREDENTIALS_PATH",
16
+ "bastion_fetch",
17
+ "BastionApiError",
18
+ ]
@@ -0,0 +1,140 @@
1
+ """Bastion API client — thin wrapper around httpx that:
2
+ - resolves credentials (env or file)
3
+ - sets Authorization: Bearer <api_key>
4
+ - prepends the server_url base
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ from typing import Any, Optional
11
+
12
+ import httpx
13
+
14
+ from .credentials import resolve_credentials, DEFAULT_SERVER_URL
15
+
16
+
17
+ class BastionApiError(Exception):
18
+ def __init__(self, message: str, *, status: Optional[int] = None, body: Any = None):
19
+ super().__init__(message)
20
+ self.status = status
21
+ self.body = body
22
+
23
+
24
+ def _base_url(creds: Optional[dict]) -> str:
25
+ if creds and creds.get("server_url"):
26
+ return creds["server_url"]
27
+ return os.environ.get("BASTION_SERVER_URL", DEFAULT_SERVER_URL)
28
+
29
+
30
+ def bastion_fetch(
31
+ path: str,
32
+ *,
33
+ method: str = "GET",
34
+ body: Any = None,
35
+ headers: Optional[dict] = None,
36
+ require_auth: bool = True,
37
+ timeout: float = 30.0,
38
+ ) -> Any:
39
+ """Synchronous authenticated HTTP request. Returns parsed JSON or None.
40
+
41
+ Raises BastionApiError on non-2xx responses (or auth missing).
42
+ """
43
+ creds = resolve_credentials()
44
+ if require_auth and not creds:
45
+ raise BastionApiError(
46
+ "Not logged in. Run `bastion login` (or set BASTION_API_KEY).",
47
+ status=401,
48
+ )
49
+ url = f"{_base_url(creds)}{path}"
50
+ final_headers = {"Content-Type": "application/json"}
51
+ if headers:
52
+ final_headers.update(headers)
53
+ if creds:
54
+ final_headers["Authorization"] = f"Bearer {creds['api_key']}"
55
+
56
+ request_body: Optional[bytes]
57
+ if body is None:
58
+ request_body = None
59
+ elif isinstance(body, (bytes, str)):
60
+ request_body = body if isinstance(body, bytes) else body.encode("utf-8")
61
+ else:
62
+ request_body = json.dumps(body).encode("utf-8")
63
+
64
+ with httpx.Client(timeout=timeout) as client:
65
+ resp = client.request(method, url, headers=final_headers, content=request_body)
66
+
67
+ if resp.status_code >= 400:
68
+ text_body = ""
69
+ try:
70
+ text_body = resp.text
71
+ except Exception:
72
+ pass
73
+ raise BastionApiError(
74
+ f"HTTP {resp.status_code} on {method} {path}: {text_body[:200]}",
75
+ status=resp.status_code,
76
+ body=text_body,
77
+ )
78
+
79
+ ct = resp.headers.get("content-type") or ""
80
+ if "json" not in ct:
81
+ return None
82
+ try:
83
+ return resp.json()
84
+ except Exception:
85
+ return None
86
+
87
+
88
+ async def bastion_fetch_async(
89
+ path: str,
90
+ *,
91
+ method: str = "GET",
92
+ body: Any = None,
93
+ headers: Optional[dict] = None,
94
+ require_auth: bool = True,
95
+ timeout: float = 30.0,
96
+ ) -> Any:
97
+ """Async equivalent of bastion_fetch."""
98
+ creds = resolve_credentials()
99
+ if require_auth and not creds:
100
+ raise BastionApiError(
101
+ "Not logged in. Run `bastion login` (or set BASTION_API_KEY).",
102
+ status=401,
103
+ )
104
+ url = f"{_base_url(creds)}{path}"
105
+ final_headers = {"Content-Type": "application/json"}
106
+ if headers:
107
+ final_headers.update(headers)
108
+ if creds:
109
+ final_headers["Authorization"] = f"Bearer {creds['api_key']}"
110
+
111
+ request_body: Optional[bytes]
112
+ if body is None:
113
+ request_body = None
114
+ elif isinstance(body, (bytes, str)):
115
+ request_body = body if isinstance(body, bytes) else body.encode("utf-8")
116
+ else:
117
+ request_body = json.dumps(body).encode("utf-8")
118
+
119
+ async with httpx.AsyncClient(timeout=timeout) as client:
120
+ resp = await client.request(method, url, headers=final_headers, content=request_body)
121
+
122
+ if resp.status_code >= 400:
123
+ text_body = ""
124
+ try:
125
+ text_body = resp.text
126
+ except Exception:
127
+ pass
128
+ raise BastionApiError(
129
+ f"HTTP {resp.status_code} on {method} {path}: {text_body[:200]}",
130
+ status=resp.status_code,
131
+ body=text_body,
132
+ )
133
+
134
+ ct = resp.headers.get("content-type") or ""
135
+ if "json" not in ct:
136
+ return None
137
+ try:
138
+ return resp.json()
139
+ except Exception:
140
+ return None
@@ -0,0 +1,66 @@
1
+ """Credentials store at ~/.bastion/credentials.json.
2
+
3
+ Resolution order:
4
+ 1. BASTION_API_KEY env var (CI path) - bypasses the file entirely
5
+ 2. ~/.bastion/credentials.json (interactive `bastion login`)
6
+
7
+ File mode is 0600. Anyone calling resolve_credentials() gets either a
8
+ dict {api_key, source, server_url, ...} or None if neither source is set.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ CREDENTIALS_DIR = Path.home() / ".bastion"
19
+ CREDENTIALS_PATH = CREDENTIALS_DIR / "credentials.json"
20
+ DEFAULT_SERVER_URL = "https://bastion-runner.pistonsolutions.ai"
21
+
22
+
23
+ def resolve_credentials() -> Optional[dict]:
24
+ env_key = os.environ.get("BASTION_API_KEY")
25
+ if env_key and env_key.strip():
26
+ return {
27
+ "api_key": env_key.strip(),
28
+ "source": "env",
29
+ "server_url": os.environ.get("BASTION_SERVER_URL", DEFAULT_SERVER_URL),
30
+ }
31
+ if not CREDENTIALS_PATH.exists():
32
+ return None
33
+ try:
34
+ data = json.loads(CREDENTIALS_PATH.read_text(encoding="utf-8"))
35
+ except (OSError, json.JSONDecodeError):
36
+ return None
37
+ if not data or not data.get("api_key"):
38
+ return None
39
+ return {**data, "source": "file"}
40
+
41
+
42
+ def save_credentials(creds: dict) -> None:
43
+ CREDENTIALS_DIR.mkdir(parents=True, exist_ok=True)
44
+ try:
45
+ os.chmod(CREDENTIALS_DIR, 0o700)
46
+ except OSError:
47
+ pass
48
+ payload = {
49
+ "api_key": creds["api_key"],
50
+ "user": creds.get("user"),
51
+ "org_id": creds.get("org_id"),
52
+ "issued_at": creds.get("issued_at", int(time.time() * 1000)),
53
+ "server_url": creds.get("server_url", DEFAULT_SERVER_URL),
54
+ }
55
+ CREDENTIALS_PATH.write_text(json.dumps(payload, indent=2), encoding="utf-8")
56
+ try:
57
+ os.chmod(CREDENTIALS_PATH, 0o600)
58
+ except OSError:
59
+ pass
60
+
61
+
62
+ def clear_credentials() -> bool:
63
+ if CREDENTIALS_PATH.exists():
64
+ CREDENTIALS_PATH.unlink()
65
+ return True
66
+ return False