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.
- bastion_red-0.4.11/.gitignore +15 -0
- bastion_red-0.4.11/LICENSE +21 -0
- bastion_red-0.4.11/PKG-INFO +150 -0
- bastion_red-0.4.11/README.md +113 -0
- bastion_red-0.4.11/pyproject.toml +88 -0
- bastion_red-0.4.11/src/bastion/__init__.py +34 -0
- bastion_red-0.4.11/src/bastion/_version.py +1 -0
- bastion_red-0.4.11/src/bastion/auth/__init__.py +18 -0
- bastion_red-0.4.11/src/bastion/auth/api_client.py +140 -0
- bastion_red-0.4.11/src/bastion/auth/credentials.py +66 -0
- bastion_red-0.4.11/src/bastion/auth/login.py +195 -0
- bastion_red-0.4.11/src/bastion/cli.py +119 -0
- bastion_red-0.4.11/src/bastion/scope.py +80 -0
- bastion_red-0.4.11/src/bastion/sdk/__init__.py +34 -0
- bastion_red-0.4.11/src/bastion/sdk/assessment.py +362 -0
- bastion_red-0.4.11/src/bastion/sdk/blue_client.py +182 -0
- bastion_red-0.4.11/src/bastion/sdk/custom_probe_runner.py +406 -0
- bastion_red-0.4.11/src/bastion/sdk/custom_scopes.py +81 -0
- bastion_red-0.4.11/src/bastion/sdk/init.py +80 -0
- bastion_red-0.4.11/src/bastion/sdk/scope_classes.py +170 -0
- bastion_red-0.4.11/src/bastion/sdk/scope_seeds.py +54 -0
- bastion_red-0.4.11/src/bastion/sdk/strategies.py +76 -0
- bastion_red-0.4.11/src/bastion/sdk/wrap.py +245 -0
- bastion_red-0.4.11/src/bastion/templates/AGENT.md +141 -0
- bastion_red-0.4.11/src/bastion/templates/SCOPE.md.example +64 -0
- bastion_red-0.4.11/src/bastion/templates/dotbastion/config.yaml +19 -0
- bastion_red-0.4.11/src/bastion/templates/dotbastion/gitignore +3 -0
- bastion_red-0.4.11/src/bastion/templates/dotbastion/scopes/custom/example.yaml.disabled +37 -0
- bastion_red-0.4.11/src/bastion/templates/dotbastion/scopes/default.md +37 -0
- bastion_red-0.4.11/src/bastion/templates/github-actions.yml +35 -0
- bastion_red-0.4.11/src/bastion/templates/techniques/index.yaml +65 -0
- bastion_red-0.4.11/tests/__init__.py +0 -0
- bastion_red-0.4.11/tests/conftest.py +31 -0
- bastion_red-0.4.11/tests/test_blue_client.py +53 -0
- bastion_red-0.4.11/tests/test_cli.py +35 -0
- bastion_red-0.4.11/tests/test_credentials.py +52 -0
- bastion_red-0.4.11/tests/test_custom_probe_runner.py +118 -0
- bastion_red-0.4.11/tests/test_custom_scopes.py +78 -0
- bastion_red-0.4.11/tests/test_qa_only_flow.py +152 -0
- bastion_red-0.4.11/tests/test_scope.py +56 -0
- bastion_red-0.4.11/tests/test_scope_classes.py +31 -0
- bastion_red-0.4.11/tests/test_wrap.py +131 -0
|
@@ -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
|