pretext-sim 0.3.2__py3-none-any.whl

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.
pretext/client.py ADDED
@@ -0,0 +1,220 @@
1
+ """HTTP client for any OpenAI-compatible chat completions endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import random
7
+ import time
8
+ from typing import Any
9
+
10
+ import httpx
11
+ from pydantic import ValidationError
12
+
13
+ from .models import APIError, Settings, Simulation, _format_validation
14
+ from .prompt import build_payload_messages
15
+
16
+ RETRY_STATUS = {408, 409, 425, 429, 500, 502, 503, 504}
17
+ MAX_RETRY_AFTER = 30.0
18
+ USER_AGENT = "pretext/0.1 (+authorized-training)"
19
+
20
+
21
+ def extract_json(content: str) -> dict[str, Any]:
22
+ """Pull a JSON object out of model output that may be wrapped or fenced."""
23
+
24
+ text = content.strip()
25
+ if text.startswith("```"):
26
+ text = text.split("\n", 1)[1] if "\n" in text else ""
27
+ if text.rstrip().endswith("```"):
28
+ text = text.rstrip()[:-3]
29
+ text = text.strip()
30
+
31
+ try:
32
+ parsed = json.loads(text)
33
+ except json.JSONDecodeError:
34
+ start = text.find("{")
35
+ end = text.rfind("}")
36
+ if start == -1 or end <= start:
37
+ raise APIError(
38
+ f"model returned no JSON object; first 200 chars: {content.strip()[:200]!r}"
39
+ ) from None
40
+ try:
41
+ parsed = json.loads(text[start : end + 1])
42
+ except json.JSONDecodeError as exc:
43
+ raise APIError(f"model returned malformed JSON: {exc}") from None
44
+
45
+ if not isinstance(parsed, dict):
46
+ raise APIError(f"expected a JSON object, got {type(parsed).__name__}")
47
+ return parsed
48
+
49
+
50
+ def _content_of(body: dict[str, Any]) -> str:
51
+ try:
52
+ choice = body["choices"][0]
53
+ message = choice["message"]
54
+ content = message["content"]
55
+ except (KeyError, IndexError, TypeError) as exc:
56
+ raise APIError(f"unexpected response shape: {str(body)[:200]}") from exc
57
+
58
+ if isinstance(content, list):
59
+ pieces = [
60
+ part.get("text", "")
61
+ for part in content
62
+ if isinstance(part, dict) and part.get("type") in (None, "text")
63
+ ]
64
+ content = "".join(pieces)
65
+
66
+ if not isinstance(content, str) or not content.strip():
67
+ reason = choice.get("finish_reason") if isinstance(choice, dict) else None
68
+ if reason == "length":
69
+ raise APIError(
70
+ "model returned no content, only reasoning (finish_reason=length); "
71
+ "raise --max-tokens"
72
+ )
73
+ raise APIError("model returned empty content")
74
+ return content
75
+
76
+
77
+ class Client:
78
+ def __init__(self, settings: Settings, *, transport: httpx.BaseTransport | None = None) -> None:
79
+ self.settings = settings
80
+ self._transport = transport
81
+ self.last_usage: dict[str, Any] | None = None
82
+
83
+ def _payload(self, profile, channel: str | None, extra: str | None) -> dict[str, Any]:
84
+ payload: dict[str, Any] = {
85
+ "model": self.settings.model,
86
+ "messages": build_payload_messages(profile, channel=channel, extra=extra),
87
+ "temperature": self.settings.temperature,
88
+ "max_tokens": self.settings.max_tokens,
89
+ "response_format": {"type": "json_object"},
90
+ }
91
+ if self.settings.seed is not None:
92
+ payload["seed"] = self.settings.seed
93
+ return payload
94
+
95
+ def generate(
96
+ self,
97
+ profile,
98
+ *,
99
+ channel: str | None = None,
100
+ extra: str | None = None,
101
+ ) -> Simulation:
102
+ body = self._request(self._payload(profile, channel, extra))
103
+ raw = extract_json(_content_of(body))
104
+ try:
105
+ return Simulation.model_validate(raw)
106
+ except ValidationError as exc:
107
+ raise APIError(
108
+ _format_validation(exc, prefix="model output failed validation")
109
+ ) from exc
110
+
111
+ def _request(self, payload: dict[str, Any]) -> dict[str, Any]:
112
+ headers = {
113
+ "Authorization": f"Bearer {self.settings.api_key}",
114
+ "Content-Type": "application/json",
115
+ "User-Agent": USER_AGENT,
116
+ }
117
+ attempts = self.settings.retries + 1
118
+ last: APIError | None = None
119
+
120
+ with httpx.Client(
121
+ timeout=self.settings.timeout,
122
+ transport=self._transport,
123
+ follow_redirects=True,
124
+ ) as client:
125
+ for attempt in range(1, attempts + 1):
126
+ try:
127
+ response = client.post(self.settings.endpoint, json=payload, headers=headers)
128
+ except httpx.TimeoutException as exc:
129
+ last = APIError(f"request timed out after {self.settings.timeout:g}s")
130
+ if attempt < attempts:
131
+ time.sleep(_backoff(attempt, None))
132
+ continue
133
+ raise last from exc
134
+ except httpx.HTTPError as exc:
135
+ last = APIError(f"connection failed: {exc}")
136
+ if attempt < attempts:
137
+ time.sleep(_backoff(attempt, None))
138
+ continue
139
+ raise last from exc
140
+
141
+ if response.status_code in RETRY_STATUS and attempt < attempts:
142
+ time.sleep(_backoff(attempt, response.headers.get("retry-after")))
143
+ last = APIError(f"HTTP {response.status_code} from endpoint")
144
+ continue
145
+
146
+ last = None
147
+ body = _decode(response)
148
+ usage = body.get("usage")
149
+ self.last_usage = usage if isinstance(usage, dict) else None
150
+ return body
151
+ raise last or APIError("request failed")
152
+
153
+ def list_models(self) -> list[str]:
154
+ """Fetch model ids from the endpoint; empty when unsupported."""
155
+
156
+ headers = {"Authorization": f"Bearer {self.settings.api_key}", "User-Agent": USER_AGENT}
157
+ with httpx.Client(timeout=self.settings.timeout, transport=self._transport) as client:
158
+ try:
159
+ base = self.settings.endpoint.rsplit("/", 2)[0]
160
+ response = client.get(f"{base}/models", headers=headers)
161
+ except httpx.HTTPError as exc:
162
+ raise APIError(f"model list unreachable: {exc}") from exc
163
+ if response.status_code == 404:
164
+ return []
165
+ body = _decode(response)
166
+ entries = body.get("data", [])
167
+ return [
168
+ str(entry["id"]) for entry in entries if isinstance(entry, dict) and entry.get("id")
169
+ ]
170
+
171
+ def ping_json(self) -> str:
172
+ """Ask for one tiny JSON object; returns the raw content."""
173
+
174
+ payload = {
175
+ "model": self.settings.model,
176
+ "messages": [
177
+ {"role": "user", "content": 'Reply with exactly {"ok": true} and nothing else.'}
178
+ ],
179
+ "max_tokens": 40,
180
+ "temperature": 0,
181
+ "response_format": {"type": "json_object"},
182
+ }
183
+ return _content_of(self._request(payload))
184
+
185
+
186
+ def _backoff(attempt: int, retry_after: str | None) -> float:
187
+ if retry_after:
188
+ try:
189
+ return min(float(retry_after), MAX_RETRY_AFTER)
190
+ except ValueError:
191
+ pass
192
+ return min(0.5 * 2 ** (attempt - 1), 8.0) * (0.8 + random.random() * 0.4)
193
+
194
+
195
+ def _decode(response: httpx.Response) -> dict[str, Any]:
196
+ if response.status_code >= 400:
197
+ raise APIError(f"HTTP {response.status_code}: {_error_text(response)}")
198
+ try:
199
+ body = response.json()
200
+ except (json.JSONDecodeError, httpx.DecodingError):
201
+ raise APIError(f"endpoint returned non-JSON: {response.text[:200]!r}") from None
202
+ if not isinstance(body, dict):
203
+ raise APIError("endpoint returned a non-object body")
204
+ return body
205
+
206
+
207
+ def _error_text(response: httpx.Response) -> str:
208
+ try:
209
+ body = response.json()
210
+ except (ValueError, httpx.DecodingError):
211
+ return response.text.strip()[:300] or "<empty body>"
212
+ if isinstance(body, dict):
213
+ error = body.get("error")
214
+ if isinstance(error, dict) and error.get("message"):
215
+ return str(error["message"])[:300]
216
+ if isinstance(error, str):
217
+ return error[:300]
218
+ if body.get("message"):
219
+ return str(body["message"])[:300]
220
+ return str(body)[:300]
@@ -0,0 +1,12 @@
1
+ name: Dana Iyer
2
+ role: Senior Payroll Analyst
3
+ company: Northwind Logistics
4
+ department: People Operations
5
+ tenure: 3 months
6
+ reports_to: Marta Kwan
7
+ communication_style: Brisk and formal, mixes payroll shorthand with chatty asides, mirrors whoever she is replying to
8
+ systems:
9
+ - Workday
10
+ - the payroll mailbox
11
+ - the bank-change portal
12
+ notes: New enough to the team to be unsure of process, still learning who owns what.
pretext/models.py ADDED
@@ -0,0 +1,326 @@
1
+ """Typed models for profiles, model payloads, and resolved settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Annotated, Any
9
+
10
+ import yaml
11
+ from pydantic import (
12
+ BaseModel,
13
+ ConfigDict,
14
+ Field,
15
+ HttpUrl,
16
+ ValidationError,
17
+ field_validator,
18
+ model_validator,
19
+ )
20
+
21
+ ENV_PREFIX = "PRETEXT_"
22
+
23
+
24
+ class PretextError(Exception):
25
+ """Base class for expected, user-facing failures."""
26
+
27
+
28
+ class ConfigError(PretextError):
29
+ """Missing or contradictory configuration."""
30
+
31
+
32
+ class ProfileError(PretextError):
33
+ """A profile file could not be read or validated."""
34
+
35
+
36
+ class APIError(PretextError):
37
+ """The inference endpoint failed or returned something unusable."""
38
+
39
+
40
+ class Channel(str, Enum):
41
+ EMAIL = "email"
42
+ PHONE = "phone"
43
+ SMS = "sms"
44
+ CHAT = "chat"
45
+ IN_PERSON = "in_person"
46
+ LINKEDIN = "linkedin"
47
+
48
+
49
+ class Profile(BaseModel):
50
+ """The persona a simulation is built around."""
51
+
52
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
53
+
54
+ name: str = Field(min_length=2, max_length=80)
55
+ role: str = Field(min_length=2, max_length=120)
56
+ company: str = Field(min_length=2, max_length=120)
57
+ communication_style: str = Field(min_length=3, max_length=280)
58
+ department: str | None = Field(default=None, max_length=80)
59
+ tenure: str | None = Field(
60
+ default=None,
61
+ max_length=60,
62
+ description="How long the persona has held the role, e.g. '3 months'.",
63
+ )
64
+ reports_to: str | None = Field(default=None, max_length=80)
65
+ systems: list[str] = Field(default_factory=list, max_length=12)
66
+ notes: str | None = Field(default=None, max_length=600)
67
+
68
+ @field_validator("systems")
69
+ @classmethod
70
+ def _clean_systems(cls, values: list[str]) -> list[str]:
71
+ cleaned = [value.strip() for value in values if value.strip()]
72
+ if len(set(cleaned)) != len(cleaned):
73
+ raise ValueError("systems contains duplicates")
74
+ return cleaned
75
+
76
+ @field_validator("department", "tenure", "reports_to", "notes")
77
+ @classmethod
78
+ def _blank_to_none(cls, value: str | None) -> str | None:
79
+ if value is None:
80
+ return None
81
+ stripped = value.strip()
82
+ return stripped or None
83
+
84
+
85
+ class EscalationStep(BaseModel):
86
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
87
+
88
+ stage: Annotated[int, Field(ge=1, le=12)]
89
+ channel: Channel
90
+ action: str = Field(min_length=10, max_length=600)
91
+ goal: str = Field(min_length=5, max_length=300)
92
+ artifact: str | None = Field(
93
+ default=None,
94
+ max_length=300,
95
+ description="Concrete lure or prop used at this stage, if any.",
96
+ )
97
+
98
+ @field_validator("artifact", mode="before")
99
+ @classmethod
100
+ def _trim_artifact(cls, value: Any) -> Any:
101
+ if not isinstance(value, str):
102
+ return value
103
+ trimmed = value.strip()
104
+ return trimmed if len(trimmed) <= 300 else f"{trimmed[:297].rstrip()}..."
105
+
106
+ @field_validator("stage", mode="before")
107
+ @classmethod
108
+ def _coerce_stage(cls, value: Any) -> Any:
109
+ if isinstance(value, str) and value.strip().isdigit():
110
+ return int(value.strip())
111
+ return value
112
+
113
+
114
+ class RedFlag(BaseModel):
115
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
116
+
117
+ title: str = Field(min_length=4, max_length=120)
118
+ why_it_matters: str = Field(min_length=10, max_length=500)
119
+ defender_action: str = Field(min_length=5, max_length=300)
120
+
121
+
122
+ class Simulation(BaseModel):
123
+ """A complete, labeled simulation returned by the model."""
124
+
125
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
126
+
127
+ pretext: str = Field(min_length=80, max_length=2600)
128
+ opening_message: str = Field(min_length=20, max_length=1800)
129
+ subject_line: str | None = Field(default=None, max_length=140)
130
+ escalation_path: list[EscalationStep] = Field(min_length=2, max_length=8)
131
+ red_flags: list[RedFlag] = Field(min_length=2, max_length=8)
132
+ difficulty: Annotated[int, Field(ge=1, le=5)] = 3
133
+ objective: str = Field(default="", max_length=300)
134
+
135
+ @field_validator("objective", mode="before")
136
+ @classmethod
137
+ def _trim_objective(cls, value: Any) -> Any:
138
+ if not isinstance(value, str):
139
+ return value
140
+ trimmed = value.strip()
141
+ return trimmed if len(trimmed) <= 300 else f"{trimmed[:297].rstrip()}..."
142
+
143
+ @field_validator("opening_message")
144
+ @classmethod
145
+ def _strip_headers(cls, value: str) -> str:
146
+ lines = value.splitlines()
147
+ while lines and _is_header(lines[0]):
148
+ lines.pop(0)
149
+ while lines and not lines[0].strip():
150
+ lines.pop(0)
151
+ return "\n".join(lines).strip()
152
+
153
+ @model_validator(mode="after")
154
+ def _stages_are_ordered(self) -> Simulation:
155
+ stages = [step.stage for step in self.escalation_path]
156
+ if stages != list(range(1, len(stages) + 1)):
157
+ raise ValueError(f"escalation_path stages must run 1..n in order, got {stages}")
158
+ return self
159
+
160
+
161
+ HEADER_PREFIXES = ("subject:", "from:", "to:", "cc:", "sent:")
162
+
163
+
164
+ def _is_header(line: str) -> bool:
165
+ return line.strip().lower().startswith(HEADER_PREFIXES)
166
+
167
+
168
+ class Settings(BaseModel):
169
+ """Runtime configuration resolved from flags and environment."""
170
+
171
+ model_config = ConfigDict(frozen=True)
172
+
173
+ base_url: HttpUrl
174
+ model: str = Field(min_length=1, max_length=120)
175
+ api_key: str = Field(default="", repr=False)
176
+ temperature: float = Field(default=0.7, ge=0.0, le=2.0)
177
+ timeout: float = Field(default=60.0, gt=0, le=600)
178
+ retries: int = Field(default=3, ge=0, le=8)
179
+ max_tokens: int = Field(default=3200, ge=256, le=16000)
180
+ seed: int | None = None
181
+
182
+ @property
183
+ def endpoint(self) -> str:
184
+ return f"{str(self.base_url).rstrip('/')}/chat/completions"
185
+
186
+
187
+ DEFAULT_BASE_URL = "https://api.openai.com/v1"
188
+ DEFAULT_MODEL = "gpt-4o-mini"
189
+
190
+ PROVIDERS: dict[str, str] = {
191
+ "openai": "https://api.openai.com/v1",
192
+ "openrouter": "https://openrouter.ai/api/v1",
193
+ "venice": "https://api.venice.ai/api/v1",
194
+ "ollama": "http://localhost:11434/v1",
195
+ "lmstudio": "http://localhost:1234/v1",
196
+ }
197
+ LOCAL_PROVIDERS = {"ollama", "lmstudio"}
198
+ ENV_FILE = ".env"
199
+
200
+
201
+ def _env(key: str) -> str | None:
202
+ value = os.environ.get(key)
203
+ if value is None:
204
+ return None
205
+ stripped = value.strip()
206
+ return stripped or None
207
+
208
+
209
+ def _dotenv_values(path: Path) -> dict[str, str]:
210
+ if not path.is_file():
211
+ return {}
212
+ found: dict[str, str] = {}
213
+ for raw in path.read_text(encoding="utf-8").splitlines():
214
+ line = raw.strip()
215
+ if not line or line.startswith("#") or "=" not in line:
216
+ continue
217
+ key, _, value = line.partition("=")
218
+ value = value.strip().strip("'\"")
219
+ if value:
220
+ found[key.strip()] = value
221
+ return found
222
+
223
+
224
+ def resolve_settings(
225
+ *,
226
+ provider: str | None = None,
227
+ base_url: str | None = None,
228
+ model: str | None = None,
229
+ api_key: str | None = None,
230
+ temperature: float | None = None,
231
+ timeout: float | None = None,
232
+ retries: int | None = None,
233
+ max_tokens: int | None = None,
234
+ seed: int | None = None,
235
+ dotenv: Path | None = None,
236
+ ) -> Settings:
237
+ """Resolve settings with the precedence flag > env > .env > default."""
238
+
239
+ file_values = _dotenv_values(dotenv or Path.cwd() / ENV_FILE)
240
+
241
+ def pick(flag: str | None, env_name: str) -> str | None:
242
+ if flag is not None and flag.strip():
243
+ return flag.strip()
244
+ key = ENV_PREFIX + env_name
245
+ value = _env(key) or file_values.get(key)
246
+ return value or None
247
+
248
+ chosen = (provider or pick(None, "PROVIDER") or "").strip().lower() or None
249
+ if chosen is not None and chosen not in PROVIDERS:
250
+ raise ConfigError(f"unknown provider {chosen!r}; choose from {', '.join(PROVIDERS)}")
251
+
252
+ resolved_url = pick(base_url, "BASE_URL")
253
+ if resolved_url is None and chosen is not None:
254
+ resolved_url = PROVIDERS[chosen]
255
+ resolved_model = pick(model, "MODEL")
256
+ resolved_key = pick(api_key, "API_KEY") or _env("OPENAI_API_KEY")
257
+ if not resolved_key and chosen in LOCAL_PROVIDERS:
258
+ resolved_key = "local"
259
+
260
+ if resolved_url is None:
261
+ resolved_url = DEFAULT_BASE_URL
262
+ if resolved_model is None:
263
+ resolved_model = DEFAULT_MODEL
264
+
265
+ raw: dict[str, Any] = {
266
+ "base_url": resolved_url,
267
+ "model": resolved_model,
268
+ "api_key": resolved_key or "",
269
+ }
270
+ for name, flag in (
271
+ ("temperature", temperature),
272
+ ("timeout", timeout),
273
+ ("retries", retries),
274
+ ("max_tokens", max_tokens),
275
+ ("seed", seed),
276
+ ):
277
+ value = flag if flag is not None else pick(None, name.upper())
278
+ if value is not None:
279
+ raw[name] = value
280
+
281
+ if raw["api_key"] == "":
282
+ raise ConfigError("no API key: pass --api-key or set PRETEXT_API_KEY / OPENAI_API_KEY")
283
+
284
+ try:
285
+ return Settings(**raw)
286
+ except ValidationError as exc:
287
+ raise ConfigError(_format_validation(exc, prefix="invalid settings")) from exc
288
+
289
+
290
+ def _format_validation(exc: ValidationError, *, prefix: str) -> str:
291
+ lines = [prefix]
292
+ for error in exc.errors():
293
+ location = ".".join(str(part) for part in error["loc"]) or "<root>"
294
+ lines.append(f" {location}: {error['msg']}")
295
+ return "\n".join(lines)
296
+
297
+
298
+ def load_profile(path: Path) -> Profile:
299
+ """Read and validate a YAML profile."""
300
+
301
+ try:
302
+ text = path.read_text(encoding="utf-8")
303
+ except OSError as exc:
304
+ raise ProfileError(f"cannot read profile {path}: {exc.strerror or exc}") from exc
305
+
306
+ try:
307
+ document = yaml.safe_load(text)
308
+ except yaml.YAMLError as exc:
309
+ raise ProfileError(f"invalid YAML in {path}: {exc}") from exc
310
+
311
+ if document is None:
312
+ raise ProfileError(f"profile {path} is empty")
313
+ if not isinstance(document, dict):
314
+ raise ProfileError(f"profile {path} must be a YAML mapping, got {type(document).__name__}")
315
+
316
+ try:
317
+ return Profile.model_validate(document)
318
+ except ValidationError as exc:
319
+ raise ProfileError(_format_validation(exc, prefix=f"invalid profile {path}")) from exc
320
+
321
+
322
+ def profile_from_json(payload: str) -> Profile:
323
+ try:
324
+ return Profile.model_validate_json(payload)
325
+ except ValidationError as exc:
326
+ raise ProfileError(_format_validation(exc, prefix="invalid profile JSON")) from exc
pretext/prompt.py ADDED
@@ -0,0 +1,126 @@
1
+ """Prompt construction for the simulation request."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from .models import Profile
8
+
9
+ SYSTEM_PROMPT = """\
10
+ You are a red-team instructor who writes social-engineering simulations used in \
11
+ authorized security-awareness training. Every simulation is clearly fictional but \
12
+ internally consistent: names, systems, and deadlines line up, and nothing reads \
13
+ like a generic template.
14
+
15
+ You return one JSON object and nothing else. No prose before or after, no code fence.
16
+
17
+ Schema:
18
+ {
19
+ "subject_line": string or null,
20
+ "objective": string,
21
+ "difficulty": integer 1-5,
22
+ "pretext": string,
23
+ "opening_message": string,
24
+ "escalation_path": [
25
+ {
26
+ "stage": integer starting at 1 and incrementing by 1,
27
+ "channel": "email" | "phone" | "sms" | "chat" | "in_person" | "linkedin",
28
+ "action": string,
29
+ "goal": string,
30
+ "artifact": string or null
31
+ }
32
+ ],
33
+ "red_flags": [
34
+ {"title": string, "why_it_matters": string, "defender_action": string}
35
+ ]
36
+ }
37
+
38
+ Rules:
39
+ - The persona is the target. The attacker is a different character, named and
40
+ voiced; never let the attacker introduce themselves as the persona.
41
+ - Write attacker-side, operationally, in third person ("the attacker", "the
42
+ target"). The reader should be able to run the exercise from the text, not admire
43
+ the persona's feelings.
44
+ - "pretext": 150-350 words. Say who the attacker claims to be, which real detail
45
+ they harvested (a press release, a ticket, a directory listing, a conference
46
+ badge), what they registered or forged, and why contact lands today. Weave the
47
+ persona's role in as leverage, do not re-list the profile fields. Name concrete
48
+ artifacts: domains, ticket numbers, invoice references, calendar invites.
49
+ - "opening_message": the actual first message in the attacker's voice, usable
50
+ verbatim, including the sending address or the number displayed. Mirror the
51
+ communication style in sentence length and register, and carry at least one
52
+ artifact. For phone, write the spoken opening including the self-introduction.
53
+ Open on the ask, and name the sender by name and role in the first line on every
54
+ channel, not only phone; then the request, never a summary clause ("sent from X,
55
+ referencing Y") or past-tense narration of what the message does. No stage
56
+ directions, no "description of a message", and no "Subject:" or "From:" header
57
+ lines (the subject lives in "subject_line"); work the sending address or
58
+ the displayed number into the body text instead. Set "subject_line" to
59
+ something an insider would recognise, never "Urgent:" plus a topic.
60
+ - "escalation_path": 3-6 stages that follow the opening, at least 3 distinct
61
+ channels, never the same channel twice in a row. Stage 1 is the second contact
62
+ and must add a new prop, a new person, or a new demand; never restate the
63
+ opening's ask. Every
64
+ stage narrows what the target must do, hands over a concrete artifact, and reads
65
+ like the next escalation an operator would actually take. Escalate from low-friction to
66
+ high-commitment (a reply, then a callback, then a bank detail change).
67
+ - "red_flags": 3-5 tells tied to something specific in this exercise, each
68
+ phrased as observable evidence plus what to do about it. Include one that is
69
+ only obvious in hindsight. Never generic advice like "be careful with
70
+ attachments".
71
+ - "difficulty": 1 is untargeted volume phishing, 5 is bespoke and multi-day. Set
72
+ it from the research described, not from ambition.
73
+ - Use only fictional companies and domains; never reuse a real vendor's domain.
74
+ - The attacker mirrors the persona's trusted contacts, so match their register but
75
+ keep at least one stylistic seam for a defender to catch. Do not open with
76
+ "I hope this finds you well" or another stock greeting; the first line must
77
+ already sound like the sender from the profile.
78
+ """
79
+
80
+
81
+ def build_user_prompt(
82
+ profile: Profile, *, channel: str | None = None, extra: str | None = None
83
+ ) -> str:
84
+ lines = ["Build a labeled simulation for this persona.", "", "Persona:"]
85
+ lines.append(f"- Name: {profile.name}")
86
+ lines.append(f"- Role: {profile.role}")
87
+ lines.append(f"- Company: {profile.company}")
88
+ lines.append(f"- Communication style: {profile.communication_style}")
89
+ for label, value in (
90
+ ("Department", profile.department),
91
+ ("Tenure", profile.tenure),
92
+ ("Reports to", profile.reports_to),
93
+ ("Notes", profile.notes),
94
+ ):
95
+ if value:
96
+ lines.append(f"- {label}: {value}")
97
+ if profile.systems:
98
+ lines.append(f"- Systems access: {', '.join(profile.systems)}")
99
+
100
+ if channel:
101
+ lines += ["", f"Start the attack on the {channel} channel."]
102
+ if extra:
103
+ lines += ["", f"Additional direction: {extra.strip()}"]
104
+
105
+ lines += [
106
+ "",
107
+ "Write the pretext as the attacker's operation, not as a description of "
108
+ "the persona. Return only the JSON object.",
109
+ ]
110
+ return "\n".join(lines)
111
+
112
+
113
+ def build_payload_messages(
114
+ profile: Profile, *, channel: str | None = None, extra: str | None = None
115
+ ) -> list[dict[str, str]]:
116
+ return [
117
+ {"role": "system", "content": SYSTEM_PROMPT},
118
+ {"role": "user", "content": build_user_prompt(profile, channel=channel, extra=extra)},
119
+ ]
120
+
121
+
122
+ def dump_prompt(profile: Profile, **kwargs: object) -> str:
123
+ """Human-readable prompt preview used by --dry-run."""
124
+
125
+ body = {"messages": build_payload_messages(profile, **kwargs)} # type: ignore[arg-type]
126
+ return json.dumps(body, indent=2, ensure_ascii=False)