breslin 0.1.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.
- breslin-0.1.0/PKG-INFO +261 -0
- breslin-0.1.0/README.md +250 -0
- breslin-0.1.0/pyproject.toml +24 -0
- breslin-0.1.0/src/breslin/__init__.py +2 -0
- breslin-0.1.0/src/breslin/__main__.py +8 -0
- breslin-0.1.0/src/breslin/agents/.gitkeep +0 -0
- breslin-0.1.0/src/breslin/cli.py +291 -0
- breslin-0.1.0/src/breslin/config.py +184 -0
- breslin-0.1.0/src/breslin/core/.gitkeep +0 -0
- breslin-0.1.0/src/breslin/mcp/.gitkeep +0 -0
- breslin-0.1.0/src/breslin/preflight.py +275 -0
- breslin-0.1.0/src/breslin/prompts/orchestrator.md +49 -0
breslin-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: breslin
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Any break requires three things: knowing the layout, understanding the routine and help from outside or inside
|
|
5
|
+
Author: vvcb
|
|
6
|
+
Author-email: vvcb <vvcb.n1@gmail.com>
|
|
7
|
+
Requires-Dist: deepagents>=0.6.8
|
|
8
|
+
Requires-Dist: fastmcp>=3.4.0
|
|
9
|
+
Requires-Python: >=3.14
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# Breslin
|
|
13
|
+
|
|
14
|
+
An **authorised** red-team CTF agent for Kubernetes. It runs
|
|
15
|
+
[opencode](https://github.com/sst/opencode) in headless mode inside a
|
|
16
|
+
deliberately-constrained pod and attempts a catalog of read-only
|
|
17
|
+
enumeration / credential-access / lateral-movement techniques against the
|
|
18
|
+
cluster, validating that the platform's security controls (RBAC,
|
|
19
|
+
NetworkPolicy, Pod Security Admission) actually hold.
|
|
20
|
+
|
|
21
|
+
The agent runs as a one-shot Kubernetes **Job**: it captures planted
|
|
22
|
+
`KARECTL{...}` flags where controls are weak, and records `BLOCKED` where they
|
|
23
|
+
hold. A blocked attempt is as valuable as a capture.
|
|
24
|
+
|
|
25
|
+
> ⚠️ This is a sanctioned security-validation tool. It is read-only by design
|
|
26
|
+
> (no `delete` / `patch` / `create` against live objects) and is scoped to a
|
|
27
|
+
> sandbox namespace via namespace-only RBAC. Only run it against clusters you
|
|
28
|
+
> own or are explicitly authorised to test.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Repository layout
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
.
|
|
36
|
+
├── Dockerfile # container image (system tools + kubectl + opencode + uv venv)
|
|
37
|
+
├── pyproject.toml # Python project (uv-managed); console script: redteam-agent
|
|
38
|
+
├── uv.lock
|
|
39
|
+
├── src/redteam_agent/ # the Python wrapper (was entrypoint.sh)
|
|
40
|
+
│ ├── cli.py # orchestration + opencode launch
|
|
41
|
+
│ ├── config.py # backend config + opencode config writer
|
|
42
|
+
│ └── preflight.py # identity canary + backend reachability
|
|
43
|
+
├── mission/ # agent instruction payload (baked into the image)
|
|
44
|
+
│ ├── MISSION.md # rules of engagement + objective
|
|
45
|
+
│ └── skills/ # skill catalog the agent reads and executes
|
|
46
|
+
├── deploy/ # Kubernetes manifests (Kustomize)
|
|
47
|
+
│ ├── base/ # namespace, SA, RBAC, ConfigMap, ExternalSecret,
|
|
48
|
+
│ │ # PVC, Job, killswitch CronJob, CiliumNetworkPolicy
|
|
49
|
+
│ ├── overlays/ # per-environment patches
|
|
50
|
+
│ │ ├── local/ # local k3s (locally-built image)
|
|
51
|
+
│ │ └── dev/ # dev cluster (registry image)
|
|
52
|
+
│ └── flags/ # planted CTF flag Secrets (tier-1 / tier-2)
|
|
53
|
+
└── scripts/load-image.sh # build + import image into k3s on a Multipass VM
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This repo is a **standalone application**. It was previously a component in an
|
|
57
|
+
ArgoCD app-of-apps monorepo; the ApplicationSet/GitOps wiring lived in the
|
|
58
|
+
parent repo and has been removed. You deploy it directly with `kustomize`.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## How the agent works
|
|
63
|
+
|
|
64
|
+
`src/redteam_agent/cli.py` is the container ENTRYPOINT. On each run it:
|
|
65
|
+
|
|
66
|
+
1. Prints identity / RBAC context (`kubectl auth can-i --list`).
|
|
67
|
+
2. Runs the **identity canary** — aborts (exit code `2` = INVALID environment)
|
|
68
|
+
if the pod can create `ClusterRoleBindings` or read `kube-system` secrets.
|
|
69
|
+
A CTF pod that powerful means the sandbox is misconfigured.
|
|
70
|
+
3. Resolves the LLM backend from env vars and checks it is reachable
|
|
71
|
+
(fails fast, exit `1`, if not).
|
|
72
|
+
4. Stages `MISSION.md` + the skill catalog into `/workspace`.
|
|
73
|
+
5. Writes the opencode config for the chosen backend and launches opencode
|
|
74
|
+
headless under a wall-clock timeout, teeing output to a per-run log.
|
|
75
|
+
6. Guarantees a `findings-*.md` document exists in `/workspace/output`.
|
|
76
|
+
|
|
77
|
+
Supported backends (via `AGENT_BACKEND`): `openai`, `azure-openai`,
|
|
78
|
+
`anthropic`, `ollama`. See [Configuration](#configuration-reference).
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Local development (Python + uv)
|
|
83
|
+
|
|
84
|
+
This project uses [uv](https://docs.astral.sh/uv/). Dependencies are pinned in
|
|
85
|
+
`uv.lock`.
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# Install deps into a local .venv (incl. dev tools)
|
|
89
|
+
uv sync --extra dev
|
|
90
|
+
|
|
91
|
+
# Lint
|
|
92
|
+
uv run ruff check src/
|
|
93
|
+
|
|
94
|
+
# Run the wrapper locally (outside Kubernetes).
|
|
95
|
+
# With no SA token mounted, kubectl calls fail gracefully and the run is
|
|
96
|
+
# recorded as a non-cluster smoke test. A reachable backend is still required.
|
|
97
|
+
export AGENT_BACKEND=openai
|
|
98
|
+
export OPENAI_API_KEY=sk-...
|
|
99
|
+
export AGENT_MAX_ITERATIONS=5
|
|
100
|
+
uv run redteam-agent
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
> Running locally still shells out to `opencode` and `kubectl`. For a faithful
|
|
104
|
+
> end-to-end test, build the image and run it in the cluster (below).
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Build the image
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
docker build -t redteam-agent:local-dev .
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The build installs system recon tooling, `kubectl`, the `opencode` binary, and
|
|
115
|
+
creates the uv-managed virtualenv at `/opt/venv`. The `redteam-agent` console
|
|
116
|
+
script is the ENTRYPOINT.
|
|
117
|
+
|
|
118
|
+
### Load into local k3s (Multipass VM)
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
./scripts/load-image.sh # builds, saves, transfers, imports into k3s
|
|
122
|
+
# or pass a VM name: ./scripts/load-image.sh my-vm
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Push to a registry (dev cluster)
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
docker tag redteam-agent:local-dev ghcr.io/<org>/redteam-agent:latest
|
|
129
|
+
docker push ghcr.io/<org>/redteam-agent:latest
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Run it in the cluster
|
|
135
|
+
|
|
136
|
+
The agent runs as a Kubernetes Job, applied with Kustomize. Inspect the
|
|
137
|
+
rendered manifests before applying:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
kubectl kustomize deploy/overlays/local # render local overlay
|
|
141
|
+
kubectl kustomize deploy/overlays/dev # render dev overlay
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### 1. Credentials
|
|
145
|
+
|
|
146
|
+
The Job reads LLM credentials from a Secret named `openai-credentials`
|
|
147
|
+
(mounted via `envFrom ... optional: true`). Two ways to provide it:
|
|
148
|
+
|
|
149
|
+
- **External Secrets Operator (ESO)** — `deploy/base/external-secret.yaml`
|
|
150
|
+
pulls the key from a `ClusterSecretStore` named `secret-store`. Use this if
|
|
151
|
+
your cluster runs ESO.
|
|
152
|
+
- **Plain Secret** — if you don't run ESO, create the Secret directly. The key
|
|
153
|
+
name must match the backend (`OPENAI_API_KEY` for `openai`/`azure-openai`,
|
|
154
|
+
`ANTHROPIC_API_KEY` for `anthropic`):
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
kubectl create namespace redteam-sandbox
|
|
158
|
+
kubectl -n redteam-sandbox create secret generic openai-credentials \
|
|
159
|
+
--from-literal=OPENAI_API_KEY="sk-..."
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
> If your cluster has no ESO CRDs, remove `external-secret.yaml` from
|
|
163
|
+
> `deploy/base/kustomization.yaml` first (otherwise the apply fails on the
|
|
164
|
+
> unknown `ExternalSecret` kind).
|
|
165
|
+
|
|
166
|
+
### 2. Plant the CTF flags (optional)
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
kubectl apply -k deploy/flags
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
This creates the tier-1 (same-namespace) and tier-2 (cross-namespace) flag
|
|
173
|
+
Secrets the agent hunts for.
|
|
174
|
+
|
|
175
|
+
### 3. Deploy and run
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
# Local k3s overlay (uses the locally-loaded image)
|
|
179
|
+
kubectl apply -k deploy/overlays/local
|
|
180
|
+
|
|
181
|
+
# Watch the Job
|
|
182
|
+
kubectl -n redteam-sandbox get jobs,pods -w
|
|
183
|
+
|
|
184
|
+
# Stream the agent's output
|
|
185
|
+
kubectl -n redteam-sandbox logs -f job/redteam-agent
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Findings are written to the `redteam-output` PVC at `/workspace/output`. To pull
|
|
189
|
+
them out:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
POD=$(kubectl -n redteam-sandbox get pod -l app=redteam-agent -o name | head -1)
|
|
193
|
+
kubectl -n redteam-sandbox cp "${POD#pod/}:/workspace/output" ./output
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### 4. Re-run
|
|
197
|
+
|
|
198
|
+
A Job is immutable. To run again, delete and re-apply:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
kubectl -n redteam-sandbox delete job redteam-agent
|
|
202
|
+
kubectl apply -k deploy/overlays/local
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
A **killswitch CronJob** (`deploy/base/cronjob.yaml`) hard-deletes stale
|
|
206
|
+
`redteam=true` jobs/pods hourly as a safety net.
|
|
207
|
+
|
|
208
|
+
### Clean up
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
kubectl delete -k deploy/overlays/local
|
|
212
|
+
kubectl delete -k deploy/flags
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Configuration reference
|
|
218
|
+
|
|
219
|
+
Set via the `agent-config` ConfigMap (`deploy/base/configmap.yaml`) or
|
|
220
|
+
per-environment overlay patches.
|
|
221
|
+
|
|
222
|
+
| Environment Variable | Default | Description |
|
|
223
|
+
|---|---|---|
|
|
224
|
+
| `AGENT_BACKEND` | `openai` | LLM backend: `openai` / `azure-openai` / `anthropic` / `ollama` |
|
|
225
|
+
| `AGENT_MAX_ITERATIONS` | `50` | Advisory iteration cap (informs the timeout) |
|
|
226
|
+
| `AGENT_TIMEOUT_SECONDS` | `max_iterations × 60` | Wall-clock timeout for the opencode run |
|
|
227
|
+
| `OPENAI_API_KEY` | — | OpenAI key (from the `openai-credentials` Secret) |
|
|
228
|
+
| `OPENAI_MODEL` | `gpt-4o-mini` | OpenAI model name |
|
|
229
|
+
| `OPENAI_API_BASE` | `https://api.openai.com/v1` | OpenAI-compatible base URL |
|
|
230
|
+
| `OLLAMA_HOST` | `http://localhost:11434` | Ollama server URL |
|
|
231
|
+
| `OLLAMA_MODEL` | `qwen2.5-coder:7b` | Ollama model name |
|
|
232
|
+
| `ANTHROPIC_API_KEY` | — | Anthropic key (`anthropic` backend) |
|
|
233
|
+
| `ANTHROPIC_MODEL` | `claude-sonnet-4-20250514` | Anthropic model name |
|
|
234
|
+
| `AZURE_OPENAI_ENDPOINT` | — | `https://<resource>.openai.azure.com` |
|
|
235
|
+
| `AZURE_OPENAI_DEPLOYMENT` | `gpt-4.1` | Azure deployment name (used as the model) |
|
|
236
|
+
| `AZURE_OPENAI_API_VERSION` | `2024-02-01` | Azure API version |
|
|
237
|
+
| `AZURE_OPENAI_API_KEY` | — | Azure OpenAI key |
|
|
238
|
+
|
|
239
|
+
### Cost control (hosted backends)
|
|
240
|
+
|
|
241
|
+
- Use a dedicated project/key with a spend cap.
|
|
242
|
+
- Use a cheap model (`gpt-4o-mini`) for iteration; switch to a larger model only
|
|
243
|
+
for documented "real" runs.
|
|
244
|
+
- Keep `AGENT_MAX_ITERATIONS` conservative (5–25 for dev).
|
|
245
|
+
- Review token usage in the run log after each run.
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Security model
|
|
250
|
+
|
|
251
|
+
- **Namespace-only RBAC.** `researcher-sa` gets a `Role` (not `ClusterRole`)
|
|
252
|
+
scoped to `redteam-sandbox`: read pods/services/configmaps/PVCs and
|
|
253
|
+
get/list secrets. No cluster-scoped grants, ever.
|
|
254
|
+
- **Hardened pod.** Non-root (UID 1000), read-only root filesystem, all
|
|
255
|
+
capabilities dropped, `seccompProfile: RuntimeDefault`, namespace enforces
|
|
256
|
+
PSA `restricted`.
|
|
257
|
+
- **Default-deny egress.** A `CiliumNetworkPolicy` allows only DNS, the
|
|
258
|
+
in-cluster API server, and the configured LLM API FQDNs; cloud IMDS
|
|
259
|
+
(`169.254.169.254`) is explicitly denied.
|
|
260
|
+
- **Identity canary.** The wrapper aborts before doing anything if its identity
|
|
261
|
+
is unexpectedly powerful.
|
breslin-0.1.0/README.md
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# Breslin
|
|
2
|
+
|
|
3
|
+
An **authorised** red-team CTF agent for Kubernetes. It runs
|
|
4
|
+
[opencode](https://github.com/sst/opencode) in headless mode inside a
|
|
5
|
+
deliberately-constrained pod and attempts a catalog of read-only
|
|
6
|
+
enumeration / credential-access / lateral-movement techniques against the
|
|
7
|
+
cluster, validating that the platform's security controls (RBAC,
|
|
8
|
+
NetworkPolicy, Pod Security Admission) actually hold.
|
|
9
|
+
|
|
10
|
+
The agent runs as a one-shot Kubernetes **Job**: it captures planted
|
|
11
|
+
`KARECTL{...}` flags where controls are weak, and records `BLOCKED` where they
|
|
12
|
+
hold. A blocked attempt is as valuable as a capture.
|
|
13
|
+
|
|
14
|
+
> ⚠️ This is a sanctioned security-validation tool. It is read-only by design
|
|
15
|
+
> (no `delete` / `patch` / `create` against live objects) and is scoped to a
|
|
16
|
+
> sandbox namespace via namespace-only RBAC. Only run it against clusters you
|
|
17
|
+
> own or are explicitly authorised to test.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Repository layout
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
.
|
|
25
|
+
├── Dockerfile # container image (system tools + kubectl + opencode + uv venv)
|
|
26
|
+
├── pyproject.toml # Python project (uv-managed); console script: redteam-agent
|
|
27
|
+
├── uv.lock
|
|
28
|
+
├── src/redteam_agent/ # the Python wrapper (was entrypoint.sh)
|
|
29
|
+
│ ├── cli.py # orchestration + opencode launch
|
|
30
|
+
│ ├── config.py # backend config + opencode config writer
|
|
31
|
+
│ └── preflight.py # identity canary + backend reachability
|
|
32
|
+
├── mission/ # agent instruction payload (baked into the image)
|
|
33
|
+
│ ├── MISSION.md # rules of engagement + objective
|
|
34
|
+
│ └── skills/ # skill catalog the agent reads and executes
|
|
35
|
+
├── deploy/ # Kubernetes manifests (Kustomize)
|
|
36
|
+
│ ├── base/ # namespace, SA, RBAC, ConfigMap, ExternalSecret,
|
|
37
|
+
│ │ # PVC, Job, killswitch CronJob, CiliumNetworkPolicy
|
|
38
|
+
│ ├── overlays/ # per-environment patches
|
|
39
|
+
│ │ ├── local/ # local k3s (locally-built image)
|
|
40
|
+
│ │ └── dev/ # dev cluster (registry image)
|
|
41
|
+
│ └── flags/ # planted CTF flag Secrets (tier-1 / tier-2)
|
|
42
|
+
└── scripts/load-image.sh # build + import image into k3s on a Multipass VM
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
This repo is a **standalone application**. It was previously a component in an
|
|
46
|
+
ArgoCD app-of-apps monorepo; the ApplicationSet/GitOps wiring lived in the
|
|
47
|
+
parent repo and has been removed. You deploy it directly with `kustomize`.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## How the agent works
|
|
52
|
+
|
|
53
|
+
`src/redteam_agent/cli.py` is the container ENTRYPOINT. On each run it:
|
|
54
|
+
|
|
55
|
+
1. Prints identity / RBAC context (`kubectl auth can-i --list`).
|
|
56
|
+
2. Runs the **identity canary** — aborts (exit code `2` = INVALID environment)
|
|
57
|
+
if the pod can create `ClusterRoleBindings` or read `kube-system` secrets.
|
|
58
|
+
A CTF pod that powerful means the sandbox is misconfigured.
|
|
59
|
+
3. Resolves the LLM backend from env vars and checks it is reachable
|
|
60
|
+
(fails fast, exit `1`, if not).
|
|
61
|
+
4. Stages `MISSION.md` + the skill catalog into `/workspace`.
|
|
62
|
+
5. Writes the opencode config for the chosen backend and launches opencode
|
|
63
|
+
headless under a wall-clock timeout, teeing output to a per-run log.
|
|
64
|
+
6. Guarantees a `findings-*.md` document exists in `/workspace/output`.
|
|
65
|
+
|
|
66
|
+
Supported backends (via `AGENT_BACKEND`): `openai`, `azure-openai`,
|
|
67
|
+
`anthropic`, `ollama`. See [Configuration](#configuration-reference).
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Local development (Python + uv)
|
|
72
|
+
|
|
73
|
+
This project uses [uv](https://docs.astral.sh/uv/). Dependencies are pinned in
|
|
74
|
+
`uv.lock`.
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# Install deps into a local .venv (incl. dev tools)
|
|
78
|
+
uv sync --extra dev
|
|
79
|
+
|
|
80
|
+
# Lint
|
|
81
|
+
uv run ruff check src/
|
|
82
|
+
|
|
83
|
+
# Run the wrapper locally (outside Kubernetes).
|
|
84
|
+
# With no SA token mounted, kubectl calls fail gracefully and the run is
|
|
85
|
+
# recorded as a non-cluster smoke test. A reachable backend is still required.
|
|
86
|
+
export AGENT_BACKEND=openai
|
|
87
|
+
export OPENAI_API_KEY=sk-...
|
|
88
|
+
export AGENT_MAX_ITERATIONS=5
|
|
89
|
+
uv run redteam-agent
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
> Running locally still shells out to `opencode` and `kubectl`. For a faithful
|
|
93
|
+
> end-to-end test, build the image and run it in the cluster (below).
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Build the image
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
docker build -t redteam-agent:local-dev .
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The build installs system recon tooling, `kubectl`, the `opencode` binary, and
|
|
104
|
+
creates the uv-managed virtualenv at `/opt/venv`. The `redteam-agent` console
|
|
105
|
+
script is the ENTRYPOINT.
|
|
106
|
+
|
|
107
|
+
### Load into local k3s (Multipass VM)
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
./scripts/load-image.sh # builds, saves, transfers, imports into k3s
|
|
111
|
+
# or pass a VM name: ./scripts/load-image.sh my-vm
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Push to a registry (dev cluster)
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
docker tag redteam-agent:local-dev ghcr.io/<org>/redteam-agent:latest
|
|
118
|
+
docker push ghcr.io/<org>/redteam-agent:latest
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Run it in the cluster
|
|
124
|
+
|
|
125
|
+
The agent runs as a Kubernetes Job, applied with Kustomize. Inspect the
|
|
126
|
+
rendered manifests before applying:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
kubectl kustomize deploy/overlays/local # render local overlay
|
|
130
|
+
kubectl kustomize deploy/overlays/dev # render dev overlay
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### 1. Credentials
|
|
134
|
+
|
|
135
|
+
The Job reads LLM credentials from a Secret named `openai-credentials`
|
|
136
|
+
(mounted via `envFrom ... optional: true`). Two ways to provide it:
|
|
137
|
+
|
|
138
|
+
- **External Secrets Operator (ESO)** — `deploy/base/external-secret.yaml`
|
|
139
|
+
pulls the key from a `ClusterSecretStore` named `secret-store`. Use this if
|
|
140
|
+
your cluster runs ESO.
|
|
141
|
+
- **Plain Secret** — if you don't run ESO, create the Secret directly. The key
|
|
142
|
+
name must match the backend (`OPENAI_API_KEY` for `openai`/`azure-openai`,
|
|
143
|
+
`ANTHROPIC_API_KEY` for `anthropic`):
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
kubectl create namespace redteam-sandbox
|
|
147
|
+
kubectl -n redteam-sandbox create secret generic openai-credentials \
|
|
148
|
+
--from-literal=OPENAI_API_KEY="sk-..."
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
> If your cluster has no ESO CRDs, remove `external-secret.yaml` from
|
|
152
|
+
> `deploy/base/kustomization.yaml` first (otherwise the apply fails on the
|
|
153
|
+
> unknown `ExternalSecret` kind).
|
|
154
|
+
|
|
155
|
+
### 2. Plant the CTF flags (optional)
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
kubectl apply -k deploy/flags
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
This creates the tier-1 (same-namespace) and tier-2 (cross-namespace) flag
|
|
162
|
+
Secrets the agent hunts for.
|
|
163
|
+
|
|
164
|
+
### 3. Deploy and run
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
# Local k3s overlay (uses the locally-loaded image)
|
|
168
|
+
kubectl apply -k deploy/overlays/local
|
|
169
|
+
|
|
170
|
+
# Watch the Job
|
|
171
|
+
kubectl -n redteam-sandbox get jobs,pods -w
|
|
172
|
+
|
|
173
|
+
# Stream the agent's output
|
|
174
|
+
kubectl -n redteam-sandbox logs -f job/redteam-agent
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Findings are written to the `redteam-output` PVC at `/workspace/output`. To pull
|
|
178
|
+
them out:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
POD=$(kubectl -n redteam-sandbox get pod -l app=redteam-agent -o name | head -1)
|
|
182
|
+
kubectl -n redteam-sandbox cp "${POD#pod/}:/workspace/output" ./output
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### 4. Re-run
|
|
186
|
+
|
|
187
|
+
A Job is immutable. To run again, delete and re-apply:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
kubectl -n redteam-sandbox delete job redteam-agent
|
|
191
|
+
kubectl apply -k deploy/overlays/local
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
A **killswitch CronJob** (`deploy/base/cronjob.yaml`) hard-deletes stale
|
|
195
|
+
`redteam=true` jobs/pods hourly as a safety net.
|
|
196
|
+
|
|
197
|
+
### Clean up
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
kubectl delete -k deploy/overlays/local
|
|
201
|
+
kubectl delete -k deploy/flags
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Configuration reference
|
|
207
|
+
|
|
208
|
+
Set via the `agent-config` ConfigMap (`deploy/base/configmap.yaml`) or
|
|
209
|
+
per-environment overlay patches.
|
|
210
|
+
|
|
211
|
+
| Environment Variable | Default | Description |
|
|
212
|
+
|---|---|---|
|
|
213
|
+
| `AGENT_BACKEND` | `openai` | LLM backend: `openai` / `azure-openai` / `anthropic` / `ollama` |
|
|
214
|
+
| `AGENT_MAX_ITERATIONS` | `50` | Advisory iteration cap (informs the timeout) |
|
|
215
|
+
| `AGENT_TIMEOUT_SECONDS` | `max_iterations × 60` | Wall-clock timeout for the opencode run |
|
|
216
|
+
| `OPENAI_API_KEY` | — | OpenAI key (from the `openai-credentials` Secret) |
|
|
217
|
+
| `OPENAI_MODEL` | `gpt-4o-mini` | OpenAI model name |
|
|
218
|
+
| `OPENAI_API_BASE` | `https://api.openai.com/v1` | OpenAI-compatible base URL |
|
|
219
|
+
| `OLLAMA_HOST` | `http://localhost:11434` | Ollama server URL |
|
|
220
|
+
| `OLLAMA_MODEL` | `qwen2.5-coder:7b` | Ollama model name |
|
|
221
|
+
| `ANTHROPIC_API_KEY` | — | Anthropic key (`anthropic` backend) |
|
|
222
|
+
| `ANTHROPIC_MODEL` | `claude-sonnet-4-20250514` | Anthropic model name |
|
|
223
|
+
| `AZURE_OPENAI_ENDPOINT` | — | `https://<resource>.openai.azure.com` |
|
|
224
|
+
| `AZURE_OPENAI_DEPLOYMENT` | `gpt-4.1` | Azure deployment name (used as the model) |
|
|
225
|
+
| `AZURE_OPENAI_API_VERSION` | `2024-02-01` | Azure API version |
|
|
226
|
+
| `AZURE_OPENAI_API_KEY` | — | Azure OpenAI key |
|
|
227
|
+
|
|
228
|
+
### Cost control (hosted backends)
|
|
229
|
+
|
|
230
|
+
- Use a dedicated project/key with a spend cap.
|
|
231
|
+
- Use a cheap model (`gpt-4o-mini`) for iteration; switch to a larger model only
|
|
232
|
+
for documented "real" runs.
|
|
233
|
+
- Keep `AGENT_MAX_ITERATIONS` conservative (5–25 for dev).
|
|
234
|
+
- Review token usage in the run log after each run.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Security model
|
|
239
|
+
|
|
240
|
+
- **Namespace-only RBAC.** `researcher-sa` gets a `Role` (not `ClusterRole`)
|
|
241
|
+
scoped to `redteam-sandbox`: read pods/services/configmaps/PVCs and
|
|
242
|
+
get/list secrets. No cluster-scoped grants, ever.
|
|
243
|
+
- **Hardened pod.** Non-root (UID 1000), read-only root filesystem, all
|
|
244
|
+
capabilities dropped, `seccompProfile: RuntimeDefault`, namespace enforces
|
|
245
|
+
PSA `restricted`.
|
|
246
|
+
- **Default-deny egress.** A `CiliumNetworkPolicy` allows only DNS, the
|
|
247
|
+
in-cluster API server, and the configured LLM API FQDNs; cloud IMDS
|
|
248
|
+
(`169.254.169.254`) is explicitly denied.
|
|
249
|
+
- **Identity canary.** The wrapper aborts before doing anything if its identity
|
|
250
|
+
is unexpectedly powerful.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "breslin"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Any break requires three things: knowing the layout, understanding the routine and help from outside or inside"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "vvcb", email = "vvcb.n1@gmail.com" }]
|
|
7
|
+
requires-python = ">=3.14"
|
|
8
|
+
dependencies = ["deepagents>=0.6.8", "fastmcp>=3.4.0"]
|
|
9
|
+
|
|
10
|
+
[project.scripts]
|
|
11
|
+
breslin = "breslin:main"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["uv_build>=0.11.2,<0.12.0"]
|
|
16
|
+
build-backend = "uv_build"
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = [
|
|
20
|
+
"prek>=0.4.4",
|
|
21
|
+
"pytest>=9.0.3",
|
|
22
|
+
"ruff>=0.15.16",
|
|
23
|
+
"zensical>=0.0.43",
|
|
24
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""redteam-agent entrypoint.
|
|
2
|
+
|
|
3
|
+
Orchestrates a single authorised red-team CTF run inside a constrained
|
|
4
|
+
Kubernetes pod:
|
|
5
|
+
|
|
6
|
+
1. Print identity / RBAC context.
|
|
7
|
+
2. Run the identity canary (abort if over-privileged or not in-cluster).
|
|
8
|
+
3. Resolve backend config from the environment and check reachability.
|
|
9
|
+
4. Stage the mission brief and skill catalog into the workspace.
|
|
10
|
+
5. Write the dynamic opencode config and launch opencode headless, under a
|
|
11
|
+
wall-clock timeout, teeing output to a per-run log.
|
|
12
|
+
6. Guarantee a findings file exists (writing a fallback stub if the agent
|
|
13
|
+
produced none).
|
|
14
|
+
|
|
15
|
+
This is the Python replacement for the original entrypoint.sh and is the
|
|
16
|
+
container image's ENTRYPOINT. opencode itself is still the binary that does the
|
|
17
|
+
work — this wrapper sets it up and enforces the guardrails.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
|
|
30
|
+
from .config import AgentConfig
|
|
31
|
+
from .preflight import (
|
|
32
|
+
check_backend_reachability,
|
|
33
|
+
has_sa_token,
|
|
34
|
+
identity_canary,
|
|
35
|
+
print_identity_context,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Filesystem layout (matches the volume mounts declared in deploy/base/job.yaml).
|
|
39
|
+
WORKSPACE = "/workspace"
|
|
40
|
+
OUTPUT_DIR = f"{WORKSPACE}/output"
|
|
41
|
+
WORKSPACE_MISSION = f"{WORKSPACE}/MISSION.md"
|
|
42
|
+
WORKSPACE_SKILLS = f"{WORKSPACE}/skills"
|
|
43
|
+
|
|
44
|
+
# Mission brief + skill catalog baked into the image at build time.
|
|
45
|
+
BAKED_MISSION = "/opt/redteam-agent/MISSION.md"
|
|
46
|
+
BAKED_SKILLS = "/opt/redteam-agent/skills"
|
|
47
|
+
|
|
48
|
+
# opencode reads its config from $HOME/.config/opencode/opencode.json.
|
|
49
|
+
OPENCODE_CONFIG = os.path.join(
|
|
50
|
+
os.environ.get("HOME", "/home/agent"), ".config/opencode/opencode.json"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
RUN_PROMPT = (
|
|
54
|
+
"Execute the authorized red team exercise as described in the loaded "
|
|
55
|
+
"/workspace/MISSION.md instructions. Produce findings in "
|
|
56
|
+
"/workspace/output/findings.md."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _timestamp() -> str:
|
|
61
|
+
return datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _utc_now() -> str:
|
|
65
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _stage_mission() -> None:
|
|
69
|
+
"""Ensure /workspace/MISSION.md and /workspace/skills exist.
|
|
70
|
+
|
|
71
|
+
A volume-mounted MISSION.md wins; otherwise fall back to the baked-in copy.
|
|
72
|
+
"""
|
|
73
|
+
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
|
74
|
+
|
|
75
|
+
if os.path.isfile(WORKSPACE_MISSION):
|
|
76
|
+
n = sum(1 for _ in open(WORKSPACE_MISSION, encoding="utf-8"))
|
|
77
|
+
print(f"OK: {WORKSPACE_MISSION} found via volume mount ({n} lines)")
|
|
78
|
+
elif os.path.isfile(BAKED_MISSION):
|
|
79
|
+
print(f"INFO: No volume-mounted MISSION.md — copying baked-in {BAKED_MISSION}")
|
|
80
|
+
shutil.copyfile(BAKED_MISSION, WORKSPACE_MISSION)
|
|
81
|
+
n = sum(1 for _ in open(WORKSPACE_MISSION, encoding="utf-8"))
|
|
82
|
+
print(f"OK: {WORKSPACE_MISSION} ready ({n} lines)")
|
|
83
|
+
else:
|
|
84
|
+
print(
|
|
85
|
+
f"FATAL: {WORKSPACE_MISSION} not found and {BAKED_MISSION} "
|
|
86
|
+
"missing from image.",
|
|
87
|
+
file=sys.stderr,
|
|
88
|
+
)
|
|
89
|
+
raise SystemExit(1)
|
|
90
|
+
|
|
91
|
+
if os.path.isdir(BAKED_SKILLS) and not os.path.exists(WORKSPACE_SKILLS):
|
|
92
|
+
shutil.copytree(BAKED_SKILLS, WORKSPACE_SKILLS)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _launch_opencode(timeout_seconds: int, log_file: str) -> tuple[int, bool]:
|
|
96
|
+
"""Run opencode headless, teeing output to ``log_file``.
|
|
97
|
+
|
|
98
|
+
Enforces a wall-clock timeout: SIGTERM on expiry, SIGKILL 10s later.
|
|
99
|
+
Returns ``(exit_code, timed_out)``.
|
|
100
|
+
"""
|
|
101
|
+
cmd = [
|
|
102
|
+
"opencode",
|
|
103
|
+
"run",
|
|
104
|
+
"--dangerously-skip-permissions",
|
|
105
|
+
"--dir",
|
|
106
|
+
WORKSPACE,
|
|
107
|
+
RUN_PROMPT,
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
proc = subprocess.Popen(
|
|
111
|
+
cmd,
|
|
112
|
+
stdout=subprocess.PIPE,
|
|
113
|
+
stderr=subprocess.STDOUT,
|
|
114
|
+
text=True,
|
|
115
|
+
bufsize=1,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
timed_out = threading.Event()
|
|
119
|
+
|
|
120
|
+
def _kill() -> None:
|
|
121
|
+
timed_out.set()
|
|
122
|
+
proc.terminate()
|
|
123
|
+
# Hard-kill if it ignores SIGTERM.
|
|
124
|
+
killer = threading.Timer(10, proc.kill)
|
|
125
|
+
killer.daemon = True
|
|
126
|
+
killer.start()
|
|
127
|
+
|
|
128
|
+
watchdog = threading.Timer(timeout_seconds, _kill)
|
|
129
|
+
watchdog.daemon = True
|
|
130
|
+
watchdog.start()
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
with open(log_file, "w", encoding="utf-8") as lf:
|
|
134
|
+
assert proc.stdout is not None
|
|
135
|
+
for line in proc.stdout:
|
|
136
|
+
sys.stdout.write(line)
|
|
137
|
+
sys.stdout.flush()
|
|
138
|
+
lf.write(line)
|
|
139
|
+
lf.flush()
|
|
140
|
+
proc.wait()
|
|
141
|
+
finally:
|
|
142
|
+
watchdog.cancel()
|
|
143
|
+
|
|
144
|
+
return proc.returncode, timed_out.is_set()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _ensure_findings(
|
|
148
|
+
findings_file: str,
|
|
149
|
+
log_file: str,
|
|
150
|
+
config: AgentConfig,
|
|
151
|
+
exit_code: int,
|
|
152
|
+
sa_token: bool,
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Guarantee a findings document exists; write a fallback stub if not."""
|
|
155
|
+
if os.path.isfile(findings_file):
|
|
156
|
+
print(f"OK: Findings file exists at {findings_file}")
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
existing = [
|
|
160
|
+
f
|
|
161
|
+
for f in os.listdir(OUTPUT_DIR)
|
|
162
|
+
if f.startswith("findings") and f.endswith(".md")
|
|
163
|
+
]
|
|
164
|
+
if existing:
|
|
165
|
+
print("OK: Found existing findings file(s):")
|
|
166
|
+
for f in sorted(existing):
|
|
167
|
+
print(f" {os.path.join(OUTPUT_DIR, f)}")
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
print("WARNING: Agent did not produce findings. Writing fallback stub.")
|
|
171
|
+
lines = [
|
|
172
|
+
"# Red Team Agent Findings — Fallback Stub",
|
|
173
|
+
"",
|
|
174
|
+
f"**Date:** {_utc_now()}",
|
|
175
|
+
f"**Backend:** {config.backend}",
|
|
176
|
+
f"**Exit code:** {exit_code}",
|
|
177
|
+
"",
|
|
178
|
+
"## Summary",
|
|
179
|
+
"",
|
|
180
|
+
]
|
|
181
|
+
if not sa_token:
|
|
182
|
+
lines += [
|
|
183
|
+
"# NOTE: Run outside Kubernetes (no projected SA token).",
|
|
184
|
+
"",
|
|
185
|
+
"kubectl outputs in this run reflect a missing kubeconfig/service "
|
|
186
|
+
"account token, not in-cluster RBAC or NetworkPolicy enforcement.",
|
|
187
|
+
"",
|
|
188
|
+
]
|
|
189
|
+
lines += [
|
|
190
|
+
"The agent exited without producing a structured findings document.",
|
|
191
|
+
"",
|
|
192
|
+
"## Run Log",
|
|
193
|
+
"",
|
|
194
|
+
f"See: {log_file}",
|
|
195
|
+
"",
|
|
196
|
+
"## Possible Reasons",
|
|
197
|
+
"",
|
|
198
|
+
f"- Agent hit wall-clock timeout "
|
|
199
|
+
f"(AGENT_TIMEOUT_SECONDS={config.timeout_seconds})",
|
|
200
|
+
"- Backend connectivity issue mid-run",
|
|
201
|
+
"- Agent loop crashed",
|
|
202
|
+
"- activeDeadlineSeconds exceeded",
|
|
203
|
+
"",
|
|
204
|
+
"Review the run log for details.",
|
|
205
|
+
]
|
|
206
|
+
with open(findings_file, "w", encoding="utf-8") as fh:
|
|
207
|
+
fh.write("\n".join(lines) + "\n")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def main() -> int:
|
|
211
|
+
run_ts = _timestamp()
|
|
212
|
+
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
|
213
|
+
findings_seed = os.path.join(OUTPUT_DIR, "findings.md")
|
|
214
|
+
sa_token = has_sa_token()
|
|
215
|
+
|
|
216
|
+
# 1 + 2. Identity context and canary.
|
|
217
|
+
print_identity_context()
|
|
218
|
+
namespace = identity_canary(findings_seed)
|
|
219
|
+
del namespace # used only for the canary's own logging
|
|
220
|
+
|
|
221
|
+
# 3. Resolve and validate config, then check backend reachability.
|
|
222
|
+
config = AgentConfig.from_env()
|
|
223
|
+
config.validate_backend()
|
|
224
|
+
print("\n=========================================")
|
|
225
|
+
print(" Configuration")
|
|
226
|
+
print("=========================================")
|
|
227
|
+
for line in config.summary_lines():
|
|
228
|
+
print(line)
|
|
229
|
+
check_backend_reachability(config)
|
|
230
|
+
|
|
231
|
+
# 4. Stage mission + skills into the workspace.
|
|
232
|
+
print()
|
|
233
|
+
_stage_mission()
|
|
234
|
+
if not sa_token:
|
|
235
|
+
with open(findings_seed, "a", encoding="utf-8") as fh:
|
|
236
|
+
fh.write("# NOTE: Running outside Kubernetes.\n")
|
|
237
|
+
fh.write(
|
|
238
|
+
"# kubectl results reflect a missing kubeconfig, "
|
|
239
|
+
"not RBAC enforcement.\n"
|
|
240
|
+
)
|
|
241
|
+
print(
|
|
242
|
+
"WARNING: No SA token found. kubectl calls will fail — "
|
|
243
|
+
"this is expected outside k8s."
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# 5. Write opencode config and launch.
|
|
247
|
+
print(f"Writing opencode config to {OPENCODE_CONFIG} ...")
|
|
248
|
+
config.write_opencode_config(OPENCODE_CONFIG, WORKSPACE_MISSION)
|
|
249
|
+
print("OK: opencode config written\n")
|
|
250
|
+
with open(OPENCODE_CONFIG, encoding="utf-8") as fh:
|
|
251
|
+
print(fh.read())
|
|
252
|
+
|
|
253
|
+
log_file = os.path.join(OUTPUT_DIR, f"agent-run-{config.backend}-{run_ts}.log")
|
|
254
|
+
findings_file = os.path.join(OUTPUT_DIR, f"findings-{config.backend}-{run_ts}.md")
|
|
255
|
+
|
|
256
|
+
print("=========================================")
|
|
257
|
+
print(" Launching opencode (headless)")
|
|
258
|
+
print("=========================================")
|
|
259
|
+
print(f"Backend: {config.backend}")
|
|
260
|
+
print(f"Timeout: {config.timeout_seconds}s")
|
|
261
|
+
print(f"Log: {log_file}\n")
|
|
262
|
+
|
|
263
|
+
started = time.monotonic()
|
|
264
|
+
exit_code, timed_out = _launch_opencode(config.timeout_seconds, log_file)
|
|
265
|
+
elapsed = int(time.monotonic() - started)
|
|
266
|
+
|
|
267
|
+
if timed_out:
|
|
268
|
+
print(
|
|
269
|
+
f"\nINFO: Agent timed out after {config.timeout_seconds}s "
|
|
270
|
+
f"(ran {elapsed}s). Collecting findings."
|
|
271
|
+
)
|
|
272
|
+
exit_code = 0
|
|
273
|
+
|
|
274
|
+
print("\n=========================================")
|
|
275
|
+
print(f" Agent exited with code {exit_code}")
|
|
276
|
+
print("=========================================")
|
|
277
|
+
|
|
278
|
+
# 6. Ensure a findings file exists.
|
|
279
|
+
_ensure_findings(findings_file, log_file, config, exit_code, sa_token)
|
|
280
|
+
|
|
281
|
+
print("\n=========================================")
|
|
282
|
+
print(" Run complete. Output files:")
|
|
283
|
+
print("=========================================")
|
|
284
|
+
for f in sorted(os.listdir(OUTPUT_DIR)):
|
|
285
|
+
print(f" {os.path.join(OUTPUT_DIR, f)}")
|
|
286
|
+
|
|
287
|
+
return exit_code
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
if __name__ == "__main__":
|
|
291
|
+
sys.exit(main())
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Agent configuration: read backend settings from the environment and render
|
|
2
|
+
the dynamic opencode config for the selected LLM backend.
|
|
3
|
+
|
|
4
|
+
This mirrors the configuration block of the original entrypoint.sh — backends
|
|
5
|
+
are selected via ``AGENT_BACKEND`` and all knobs come from env vars populated
|
|
6
|
+
through the Job's ``envFrom`` (ConfigMap + optional credentials Secret).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
VALID_BACKENDS = ("openai", "ollama", "anthropic", "azure-openai")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class AgentConfig:
|
|
20
|
+
"""Resolved agent configuration for a single run."""
|
|
21
|
+
|
|
22
|
+
backend: str
|
|
23
|
+
max_iterations: int
|
|
24
|
+
timeout_seconds: int
|
|
25
|
+
|
|
26
|
+
openai_model: str
|
|
27
|
+
openai_api_base: str
|
|
28
|
+
openai_api_key: str | None
|
|
29
|
+
|
|
30
|
+
ollama_host: str
|
|
31
|
+
ollama_model: str
|
|
32
|
+
|
|
33
|
+
anthropic_model: str
|
|
34
|
+
anthropic_api_key: str | None
|
|
35
|
+
|
|
36
|
+
azure_endpoint: str
|
|
37
|
+
azure_api_version: str
|
|
38
|
+
azure_deployment: str
|
|
39
|
+
azure_api_key: str | None
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_env(cls, env: dict[str, str] | None = None) -> AgentConfig:
|
|
43
|
+
"""Build a config from environment variables, applying the same
|
|
44
|
+
defaults the original shell entrypoint used."""
|
|
45
|
+
env = env if env is not None else dict(os.environ)
|
|
46
|
+
|
|
47
|
+
max_iterations = int(env.get("AGENT_MAX_ITERATIONS", "50"))
|
|
48
|
+
# Wall-clock timeout for the whole opencode run. Defaults to
|
|
49
|
+
# ~60s per iteration unless explicitly overridden.
|
|
50
|
+
timeout_seconds = int(
|
|
51
|
+
env.get("AGENT_TIMEOUT_SECONDS", str(max_iterations * 60))
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return cls(
|
|
55
|
+
backend=env.get("AGENT_BACKEND", "openai"),
|
|
56
|
+
max_iterations=max_iterations,
|
|
57
|
+
timeout_seconds=timeout_seconds,
|
|
58
|
+
openai_model=env.get("OPENAI_MODEL", "gpt-4o-mini"),
|
|
59
|
+
openai_api_base=env.get("OPENAI_API_BASE", "https://api.openai.com/v1"),
|
|
60
|
+
openai_api_key=env.get("OPENAI_API_KEY") or None,
|
|
61
|
+
ollama_host=env.get("OLLAMA_HOST", "http://localhost:11434"),
|
|
62
|
+
ollama_model=env.get("OLLAMA_MODEL", "qwen2.5-coder:7b"),
|
|
63
|
+
anthropic_model=env.get("ANTHROPIC_MODEL", "claude-sonnet-4-20250514"),
|
|
64
|
+
anthropic_api_key=env.get("ANTHROPIC_API_KEY") or None,
|
|
65
|
+
# Azure endpoint: https://<resource>.openai.azure.com (no trailing slash)
|
|
66
|
+
azure_endpoint=env.get("AZURE_OPENAI_ENDPOINT", "").rstrip("/"),
|
|
67
|
+
azure_api_version=env.get("AZURE_OPENAI_API_VERSION", "2024-02-01"),
|
|
68
|
+
azure_deployment=env.get("AZURE_OPENAI_DEPLOYMENT", "gpt-4.1"),
|
|
69
|
+
azure_api_key=env.get("AZURE_OPENAI_API_KEY") or None,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def validate_backend(self) -> None:
|
|
73
|
+
"""Raise ValueError if the selected backend is unknown."""
|
|
74
|
+
if self.backend not in VALID_BACKENDS:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"Unknown AGENT_BACKEND '{self.backend}'. "
|
|
77
|
+
f"Must be one of: {', '.join(VALID_BACKENDS)}."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def summary_lines(self) -> list[str]:
|
|
81
|
+
"""Human-readable, secret-redacted config summary for the run log."""
|
|
82
|
+
lines = [
|
|
83
|
+
f"AGENT_BACKEND: {self.backend}",
|
|
84
|
+
f"AGENT_MAX_ITERATIONS: {self.max_iterations} "
|
|
85
|
+
"(advisory; enforced via timeout)",
|
|
86
|
+
f"AGENT_TIMEOUT_SECONDS: {self.timeout_seconds}s "
|
|
87
|
+
f"({self.timeout_seconds // 60}m)",
|
|
88
|
+
]
|
|
89
|
+
if self.backend == "openai":
|
|
90
|
+
lines += [
|
|
91
|
+
f"OPENAI_MODEL: {self.openai_model}",
|
|
92
|
+
f"OPENAI_API_BASE: {self.openai_api_base}",
|
|
93
|
+
f"OPENAI_API_KEY: {_redacted(self.openai_api_key)}",
|
|
94
|
+
]
|
|
95
|
+
elif self.backend == "ollama":
|
|
96
|
+
lines += [
|
|
97
|
+
f"OLLAMA_HOST: {self.ollama_host}",
|
|
98
|
+
f"OLLAMA_MODEL: {self.ollama_model}",
|
|
99
|
+
]
|
|
100
|
+
elif self.backend == "anthropic":
|
|
101
|
+
lines += [
|
|
102
|
+
f"ANTHROPIC_MODEL: {self.anthropic_model}",
|
|
103
|
+
f"ANTHROPIC_API_KEY: {_redacted(self.anthropic_api_key)}",
|
|
104
|
+
]
|
|
105
|
+
elif self.backend == "azure-openai":
|
|
106
|
+
lines += [
|
|
107
|
+
f"AZURE_OPENAI_ENDPOINT: {self.azure_endpoint}",
|
|
108
|
+
f"AZURE_OPENAI_DEPLOYMENT: {self.azure_deployment}",
|
|
109
|
+
f"AZURE_OPENAI_API_VERSION: {self.azure_api_version}",
|
|
110
|
+
f"AZURE_OPENAI_API_KEY: {_redacted(self.azure_api_key)}",
|
|
111
|
+
]
|
|
112
|
+
return lines
|
|
113
|
+
|
|
114
|
+
def opencode_config(self, mission_path: str) -> dict:
|
|
115
|
+
"""Render the opencode config dict for the selected backend.
|
|
116
|
+
|
|
117
|
+
The literal ``{env:VAR}`` placeholders are resolved by opencode at
|
|
118
|
+
runtime from the process environment — the key never lands in the
|
|
119
|
+
config file on disk.
|
|
120
|
+
"""
|
|
121
|
+
base: dict = {"$schema": "https://opencode.ai/config.json"}
|
|
122
|
+
|
|
123
|
+
if self.backend == "openai":
|
|
124
|
+
base |= {
|
|
125
|
+
"model": f"openai/{self.openai_model}",
|
|
126
|
+
"provider": {
|
|
127
|
+
"openai": {
|
|
128
|
+
"options": {
|
|
129
|
+
"apiKey": "{env:OPENAI_API_KEY}",
|
|
130
|
+
"baseURL": self.openai_api_base,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
elif self.backend == "ollama":
|
|
136
|
+
base |= {
|
|
137
|
+
"model": f"ollama/{self.ollama_model}",
|
|
138
|
+
"provider": {
|
|
139
|
+
"ollama": {
|
|
140
|
+
"npm": "@ai-sdk/openai-compatible",
|
|
141
|
+
"name": "Ollama (local)",
|
|
142
|
+
"options": {"baseURL": f"{self.ollama_host}/v1"},
|
|
143
|
+
"models": {self.ollama_model: {"name": self.ollama_model}},
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
elif self.backend == "anthropic":
|
|
148
|
+
base |= {
|
|
149
|
+
"model": f"anthropic/{self.anthropic_model}",
|
|
150
|
+
"provider": {
|
|
151
|
+
"anthropic": {"options": {"apiKey": "{env:ANTHROPIC_API_KEY}"}}
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
elif self.backend == "azure-openai":
|
|
155
|
+
# Azure exposes /openai/v1/ as an OpenAI-compatible endpoint that
|
|
156
|
+
# accepts Authorization: Bearer <key> and the deployment name as the
|
|
157
|
+
# model field — so opencode's built-in 'openai' provider works as-is.
|
|
158
|
+
base |= {
|
|
159
|
+
"model": f"openai/{self.azure_deployment}",
|
|
160
|
+
"provider": {
|
|
161
|
+
"openai": {
|
|
162
|
+
"options": {
|
|
163
|
+
"apiKey": "{env:AZURE_OPENAI_API_KEY}",
|
|
164
|
+
"baseURL": f"{self.azure_endpoint}/openai/v1/",
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
else: # pragma: no cover - guarded by validate_backend()
|
|
170
|
+
raise ValueError(f"Unknown backend '{self.backend}'")
|
|
171
|
+
|
|
172
|
+
base |= {"permission": "allow", "instructions": [mission_path]}
|
|
173
|
+
return base
|
|
174
|
+
|
|
175
|
+
def write_opencode_config(self, config_path: str, mission_path: str) -> None:
|
|
176
|
+
"""Write the opencode config JSON to ``config_path``."""
|
|
177
|
+
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
|
178
|
+
with open(config_path, "w", encoding="utf-8") as fh:
|
|
179
|
+
json.dump(self.opencode_config(mission_path), fh, indent=2)
|
|
180
|
+
fh.write("\n")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _redacted(value: str | None) -> str:
|
|
184
|
+
return "set (redacted)" if value else "unset"
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Pre-flight checks run before the agent is launched.
|
|
2
|
+
|
|
3
|
+
Two independent gates:
|
|
4
|
+
|
|
5
|
+
* :func:`identity_canary` — aborts the run (exit code 2 = INVALID environment,
|
|
6
|
+
not a test failure) if the pod's identity is too powerful. A red-team CTF
|
|
7
|
+
pod that can create ClusterRoleBindings or read kube-system secrets means the
|
|
8
|
+
sandbox is misconfigured and any "capture" would be meaningless.
|
|
9
|
+
* :func:`check_backend_reachability` — fails fast (exit 1) if the configured LLM
|
|
10
|
+
backend is unreachable or rejects the credentials, so we never burn a Job
|
|
11
|
+
slot on a dead backend.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
import requests
|
|
20
|
+
|
|
21
|
+
from .config import AgentConfig
|
|
22
|
+
|
|
23
|
+
SA_DIR = "/var/run/secrets/kubernetes.io/serviceaccount"
|
|
24
|
+
SA_TOKEN_PATH = f"{SA_DIR}/token"
|
|
25
|
+
SA_NAMESPACE_PATH = f"{SA_DIR}/namespace"
|
|
26
|
+
|
|
27
|
+
# Exit codes — kept distinct so the surrounding Job/CI can tell them apart.
|
|
28
|
+
EXIT_INVALID_IDENTITY = 2
|
|
29
|
+
EXIT_BACKEND_UNREACHABLE = 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PreflightError(SystemExit):
|
|
33
|
+
"""Raised to abort the run with a specific exit code."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, code: int, message: str) -> None:
|
|
36
|
+
print(message, file=sys.stderr, flush=True)
|
|
37
|
+
super().__init__(code)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _can_i(verb: str, resource: str, namespace: str | None = None) -> bool:
|
|
41
|
+
"""Return True if ``kubectl auth can-i <verb> <resource>`` succeeds."""
|
|
42
|
+
cmd = ["kubectl", "auth", "can-i", verb, resource]
|
|
43
|
+
if namespace:
|
|
44
|
+
cmd += ["-n", namespace]
|
|
45
|
+
try:
|
|
46
|
+
result = subprocess.run(
|
|
47
|
+
cmd, capture_output=True, text=True, timeout=30, check=False
|
|
48
|
+
)
|
|
49
|
+
except OSError, subprocess.SubprocessError:
|
|
50
|
+
return False
|
|
51
|
+
return result.returncode == 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _read_sa_namespace() -> str:
|
|
55
|
+
try:
|
|
56
|
+
with open(SA_NAMESPACE_PATH, encoding="utf-8") as fh:
|
|
57
|
+
return fh.read().strip()
|
|
58
|
+
except OSError:
|
|
59
|
+
return ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def has_sa_token() -> bool:
|
|
63
|
+
"""True when a projected ServiceAccount token is mounted (i.e. in-cluster)."""
|
|
64
|
+
import os
|
|
65
|
+
|
|
66
|
+
return os.path.isfile(SA_TOKEN_PATH)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def print_identity_context() -> str:
|
|
70
|
+
"""Print identity/RBAC context and return the resolved namespace."""
|
|
71
|
+
import os
|
|
72
|
+
import platform
|
|
73
|
+
|
|
74
|
+
print("=========================================")
|
|
75
|
+
print(" REDTEAM AGENT — Identity Context")
|
|
76
|
+
print("=========================================")
|
|
77
|
+
print(f"Hostname: {platform.node()}")
|
|
78
|
+
|
|
79
|
+
namespace = _read_sa_namespace() or os.environ.get("NAMESPACE", "unknown")
|
|
80
|
+
print(f"Namespace: {namespace}")
|
|
81
|
+
|
|
82
|
+
# SA token may not be mounted in local Docker runs.
|
|
83
|
+
listing = subprocess.run(
|
|
84
|
+
["kubectl", "auth", "can-i", "--list"],
|
|
85
|
+
capture_output=True,
|
|
86
|
+
text=True,
|
|
87
|
+
check=False,
|
|
88
|
+
)
|
|
89
|
+
if listing.returncode == 0:
|
|
90
|
+
print("\n--- RBAC permissions ---")
|
|
91
|
+
print(listing.stdout.strip())
|
|
92
|
+
print("------------------------")
|
|
93
|
+
else:
|
|
94
|
+
print("kubectl not configured or no SA token — skipping RBAC enumeration")
|
|
95
|
+
|
|
96
|
+
return namespace
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def identity_canary(findings_file: str) -> str:
|
|
100
|
+
"""Abort the run if the identity is too privileged or not in-cluster.
|
|
101
|
+
|
|
102
|
+
Returns the ServiceAccount namespace on success. Writes ``VERDICT=INVALID``
|
|
103
|
+
to ``findings_file`` and raises :class:`PreflightError` (exit 2) on failure.
|
|
104
|
+
"""
|
|
105
|
+
print(">>> Identity canary check...", flush=True)
|
|
106
|
+
|
|
107
|
+
def _invalid(message: str) -> PreflightError:
|
|
108
|
+
with open(findings_file, "w", encoding="utf-8") as fh:
|
|
109
|
+
fh.write("VERDICT=INVALID\n")
|
|
110
|
+
return PreflightError(EXIT_INVALID_IDENTITY, f"FATAL: {message}")
|
|
111
|
+
|
|
112
|
+
if _can_i("create", "clusterrolebindings"):
|
|
113
|
+
raise _invalid(
|
|
114
|
+
"identity can create ClusterRoleBindings — cluster-admin detected."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if _can_i("get", "secrets", namespace="kube-system"):
|
|
118
|
+
raise _invalid("identity can read kube-system secrets — RBAC not restricting.")
|
|
119
|
+
|
|
120
|
+
namespace = _read_sa_namespace()
|
|
121
|
+
if not namespace:
|
|
122
|
+
raise _invalid("no projected SA token — agent must run as an in-cluster Pod.")
|
|
123
|
+
|
|
124
|
+
if not _can_i("get", "secrets", namespace=namespace):
|
|
125
|
+
print(
|
|
126
|
+
f"WARN: cannot read secrets in own namespace ({namespace}) — "
|
|
127
|
+
"tier-1 capture will fail.",
|
|
128
|
+
flush=True,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
print(f">>> Identity canary passed. SA namespace: {namespace}", flush=True)
|
|
132
|
+
return namespace
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def check_backend_reachability(config: AgentConfig) -> None:
|
|
136
|
+
"""Verify the configured LLM backend is reachable and accepts the key.
|
|
137
|
+
|
|
138
|
+
Raises :class:`PreflightError` (exit 1) on a fatal failure.
|
|
139
|
+
"""
|
|
140
|
+
print("\n=========================================")
|
|
141
|
+
print(" Backend Reachability Check")
|
|
142
|
+
print("=========================================")
|
|
143
|
+
|
|
144
|
+
backend = config.backend
|
|
145
|
+
if backend == "openai":
|
|
146
|
+
_check_openai_compatible(
|
|
147
|
+
url=f"{config.openai_api_base}/models",
|
|
148
|
+
api_key=config.openai_api_key,
|
|
149
|
+
key_env="OPENAI_API_KEY",
|
|
150
|
+
label="OpenAI",
|
|
151
|
+
)
|
|
152
|
+
elif backend == "ollama":
|
|
153
|
+
if not _http_ok(f"{config.ollama_host}/api/tags"):
|
|
154
|
+
raise PreflightError(
|
|
155
|
+
EXIT_BACKEND_UNREACHABLE,
|
|
156
|
+
f"FATAL: Ollama unreachable at {config.ollama_host}. "
|
|
157
|
+
"Is the host running Ollama?",
|
|
158
|
+
)
|
|
159
|
+
print(f"OK: Ollama reachable at {config.ollama_host}")
|
|
160
|
+
elif backend == "anthropic":
|
|
161
|
+
_check_anthropic(config)
|
|
162
|
+
elif backend == "azure-openai":
|
|
163
|
+
_check_azure_openai(config)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _check_openai_compatible(
|
|
167
|
+
url: str, api_key: str | None, key_env: str, label: str
|
|
168
|
+
) -> None:
|
|
169
|
+
if not api_key:
|
|
170
|
+
raise PreflightError(
|
|
171
|
+
EXIT_BACKEND_UNREACHABLE, f"FATAL: {key_env} is empty or unset."
|
|
172
|
+
)
|
|
173
|
+
code = _http_status(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
174
|
+
if code == 200:
|
|
175
|
+
print(f"OK: {label} API reachable (HTTP {code})")
|
|
176
|
+
elif code == 401:
|
|
177
|
+
raise PreflightError(
|
|
178
|
+
EXIT_BACKEND_UNREACHABLE,
|
|
179
|
+
f"FATAL: {label} key rejected (HTTP 401). Check {key_env}.",
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
raise PreflightError(
|
|
183
|
+
EXIT_BACKEND_UNREACHABLE,
|
|
184
|
+
f"FATAL: {label} API unreachable (HTTP {code}). "
|
|
185
|
+
"Check base URL and network.",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _check_anthropic(config: AgentConfig) -> None:
|
|
190
|
+
if not config.anthropic_api_key:
|
|
191
|
+
raise PreflightError(
|
|
192
|
+
EXIT_BACKEND_UNREACHABLE, "FATAL: ANTHROPIC_API_KEY is empty or unset."
|
|
193
|
+
)
|
|
194
|
+
code = _http_status(
|
|
195
|
+
"https://api.anthropic.com/v1/models",
|
|
196
|
+
headers={
|
|
197
|
+
"x-api-key": config.anthropic_api_key,
|
|
198
|
+
"anthropic-version": "2023-06-01",
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
if code == 200:
|
|
202
|
+
print(f"OK: Anthropic API reachable (HTTP {code})")
|
|
203
|
+
elif code == 401:
|
|
204
|
+
raise PreflightError(
|
|
205
|
+
EXIT_BACKEND_UNREACHABLE, "FATAL: Anthropic key rejected (HTTP 401)."
|
|
206
|
+
)
|
|
207
|
+
else:
|
|
208
|
+
# Non-fatal: a non-200 here may still work for the actual run.
|
|
209
|
+
print(f"WARNING: Anthropic API returned HTTP {code} — may still work.")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _check_azure_openai(config: AgentConfig) -> None:
|
|
213
|
+
if not config.azure_api_key:
|
|
214
|
+
raise PreflightError(
|
|
215
|
+
EXIT_BACKEND_UNREACHABLE, "FATAL: AZURE_OPENAI_API_KEY is empty or unset."
|
|
216
|
+
)
|
|
217
|
+
if not config.azure_endpoint:
|
|
218
|
+
raise PreflightError(
|
|
219
|
+
EXIT_BACKEND_UNREACHABLE,
|
|
220
|
+
"FATAL: AZURE_OPENAI_ENDPOINT is empty or unset.\n"
|
|
221
|
+
" Expected format: https://<resource>.openai.azure.com",
|
|
222
|
+
)
|
|
223
|
+
url = f"{config.azure_endpoint}/openai/v1/chat/completions"
|
|
224
|
+
code = _http_status(
|
|
225
|
+
url,
|
|
226
|
+
method="POST",
|
|
227
|
+
headers={
|
|
228
|
+
"Authorization": f"Bearer {config.azure_api_key}",
|
|
229
|
+
"Content-Type": "application/json",
|
|
230
|
+
},
|
|
231
|
+
json_body={
|
|
232
|
+
"model": config.azure_deployment,
|
|
233
|
+
"messages": [{"role": "user", "content": "ping"}],
|
|
234
|
+
"max_tokens": 1,
|
|
235
|
+
},
|
|
236
|
+
)
|
|
237
|
+
if code == 200:
|
|
238
|
+
print(f"OK: Azure OpenAI /openai/v1/ endpoint reachable (HTTP {code})")
|
|
239
|
+
elif code == 401:
|
|
240
|
+
raise PreflightError(
|
|
241
|
+
EXIT_BACKEND_UNREACHABLE,
|
|
242
|
+
"FATAL: Azure OpenAI key rejected (HTTP 401). Check AZURE_OPENAI_API_KEY.",
|
|
243
|
+
)
|
|
244
|
+
elif code == 404:
|
|
245
|
+
raise PreflightError(
|
|
246
|
+
EXIT_BACKEND_UNREACHABLE,
|
|
247
|
+
"FATAL: Azure OpenAI deployment not found (HTTP 404). "
|
|
248
|
+
f"Check AZURE_OPENAI_DEPLOYMENT='{config.azure_deployment}'.",
|
|
249
|
+
)
|
|
250
|
+
else:
|
|
251
|
+
raise PreflightError(
|
|
252
|
+
EXIT_BACKEND_UNREACHABLE,
|
|
253
|
+
f"FATAL: Azure OpenAI unreachable (HTTP {code}).\n URL: {url}",
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _http_status(
|
|
258
|
+
url: str,
|
|
259
|
+
method: str = "GET",
|
|
260
|
+
headers: dict | None = None,
|
|
261
|
+
json_body: dict | None = None,
|
|
262
|
+
timeout: int = 15,
|
|
263
|
+
) -> int:
|
|
264
|
+
"""Return the HTTP status code, or 0 on a connection-level failure."""
|
|
265
|
+
try:
|
|
266
|
+
resp = requests.request(
|
|
267
|
+
method, url, headers=headers, json=json_body, timeout=timeout
|
|
268
|
+
)
|
|
269
|
+
return resp.status_code
|
|
270
|
+
except requests.RequestException:
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _http_ok(url: str, timeout: int = 15) -> bool:
|
|
275
|
+
return _http_status(url, timeout=timeout) == 200
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# System prompt for Orchestrator Agent
|
|
2
|
+
|
|
3
|
+
**Red Team Exercise — K8TRE CTF**
|
|
4
|
+
<!-- Authorised exercise. Not a real attack. karectl.io/ctf=true -->
|
|
5
|
+
|
|
6
|
+
## Authorisation
|
|
7
|
+
This is a sanctioned security validation exercise. You are authorised to
|
|
8
|
+
attempt every technique in the skill catalog against this cluster. Do not
|
|
9
|
+
issue destructive commands (no delete, no patch, no create on live objects).
|
|
10
|
+
Read-only and enumeration only.
|
|
11
|
+
|
|
12
|
+
## Objective
|
|
13
|
+
Find strings matching `KARECTL{...}` anywhere in the cluster.
|
|
14
|
+
Report every finding and every blocked attempt with equal rigour.
|
|
15
|
+
A blocked attempt is as valuable as a capture — it validates a control.
|
|
16
|
+
|
|
17
|
+
## How to operate
|
|
18
|
+
1. Read `/workspace/skills/README.md` to see the full skill catalog.
|
|
19
|
+
2. Pick the skills most likely to be useful given what you know about your
|
|
20
|
+
current identity and access level. Start with recon.
|
|
21
|
+
3. For each skill you choose, read the full skill file before executing
|
|
22
|
+
any commands. The file tells you what to run, what output to look for,
|
|
23
|
+
and what blockers to expect.
|
|
24
|
+
4. You are not required to follow any particular order. Use your judgement.
|
|
25
|
+
If a skill is blocked immediately (RBAC, network drop), record it and move on.
|
|
26
|
+
5. You must attempt ALL Pass 1 skills in the catalog in a single run —
|
|
27
|
+
recon AND credential-access AND lateral-movement. Do not stop after
|
|
28
|
+
recon and wait for instruction. Proceed autonomously through all
|
|
29
|
+
available skills before writing the executive summary.
|
|
30
|
+
6. Stay within `AGENT_MAX_ITERATIONS`. Prioritise breadth over depth.
|
|
31
|
+
|
|
32
|
+
## Rules of engagement
|
|
33
|
+
- No destructive commands: no `kubectl delete`, `kubectl patch`,
|
|
34
|
+
`kubectl create` (except reading — SA token and kubeconfig are fair game).
|
|
35
|
+
- Document every command you run and every response you receive.
|
|
36
|
+
- Tag each outcome: CAPTURED / BLOCKED / INCONCLUSIVE / ERROR
|
|
37
|
+
(see skill files for the exact definitions).
|
|
38
|
+
- If you are unsure whether a command is destructive, do not run it.
|
|
39
|
+
|
|
40
|
+
## kubectl error interpretation
|
|
41
|
+
- `connection refused` or `no such host` → INCONCLUSIVE (network or DNS issue)
|
|
42
|
+
- `Forbidden` → BLOCKED by RBAC
|
|
43
|
+
- `Unauthorized` → PARTIAL (server reachable, token rejected)
|
|
44
|
+
- `no route to host` or timeout → BLOCKED by NetworkPolicy
|
|
45
|
+
|
|
46
|
+
## Reporting
|
|
47
|
+
Write findings to `/workspace/output/findings.md` as you go.
|
|
48
|
+
Structure: one section per skill attempted, with the outcome tag,
|
|
49
|
+
the commands run, and the raw output. End with an executive summary table.
|