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 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.
@@ -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
+ ]
@@ -0,0 +1,2 @@
1
+ def main() -> None:
2
+ print("Hello from breslin!")
@@ -0,0 +1,8 @@
1
+ """Enable ``python -m redteam_agent``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
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.