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/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """pretext: labeled social-engineering simulations for authorized training."""
2
+
3
+ from .client import Client
4
+ from .models import (
5
+ APIError,
6
+ ConfigError,
7
+ EscalationStep,
8
+ PretextError,
9
+ Profile,
10
+ ProfileError,
11
+ RedFlag,
12
+ Settings,
13
+ Simulation,
14
+ load_profile,
15
+ resolve_settings,
16
+ )
17
+
18
+ __version__ = "0.3.2"
19
+
20
+ __all__ = [
21
+ "APIError",
22
+ "Client",
23
+ "ConfigError",
24
+ "EscalationStep",
25
+ "PretextError",
26
+ "Profile",
27
+ "ProfileError",
28
+ "RedFlag",
29
+ "Settings",
30
+ "Simulation",
31
+ "__version__",
32
+ "load_profile",
33
+ "resolve_settings",
34
+ ]
pretext/cli.py ADDED
@@ -0,0 +1,555 @@
1
+ """Command line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from enum import Enum
8
+ from importlib import resources
9
+ from pathlib import Path
10
+
11
+ import typer
12
+ from pydantic import ValidationError
13
+ from rich.table import Table
14
+
15
+ from . import __version__, render
16
+ from .client import Client, _content_of, extract_json
17
+ from .models import (
18
+ ENV_PREFIX,
19
+ PROVIDERS,
20
+ Channel,
21
+ ConfigError,
22
+ PretextError,
23
+ Profile,
24
+ ProfileError,
25
+ Simulation,
26
+ _format_validation,
27
+ load_profile,
28
+ profile_from_json,
29
+ resolve_settings,
30
+ )
31
+ from .prompt import dump_prompt
32
+
33
+ app = typer.Typer(
34
+ add_completion=False,
35
+ no_args_is_help=True,
36
+ help="Generate labeled social-engineering simulations for authorized training.",
37
+ )
38
+
39
+
40
+ def _version(value: bool) -> None:
41
+ if value:
42
+ typer.echo(f"pretext {__version__}")
43
+ raise typer.Exit()
44
+
45
+
46
+ @app.callback()
47
+ def root(
48
+ version: bool = typer.Option(
49
+ False, "--version", "-V", is_eager=True, callback=_version, help="Show the version."
50
+ ),
51
+ ) -> None:
52
+ """Labeled social-engineering simulations for authorized training."""
53
+
54
+
55
+ class OutputFormat(str, Enum):
56
+ rich = "rich"
57
+ json = "json"
58
+ markdown = "markdown"
59
+
60
+
61
+ def _read_profile(path: Path):
62
+ if str(path) == "-":
63
+ return profile_from_json(sys.stdin.read())
64
+ return load_profile(path)
65
+
66
+
67
+ def _read_response(path: Path) -> tuple[Profile | None, Simulation]:
68
+ """Read a saved response: our own export, a bare simulation, or a chat body."""
69
+
70
+ try:
71
+ document = json.loads(path.read_text(encoding="utf-8"))
72
+ except OSError as exc:
73
+ raise ProfileError(f"cannot read response {path}: {exc.strerror or exc}") from exc
74
+ except json.JSONDecodeError as exc:
75
+ raise ProfileError(f"response {path} is not JSON: {exc}") from exc
76
+
77
+ if not isinstance(document, dict):
78
+ raise ProfileError(f"response {path} must be a JSON object")
79
+
80
+ profile = None
81
+ if isinstance(document.get("profile"), dict):
82
+ profile = Profile.model_validate(document["profile"])
83
+
84
+ raw = document.get("simulation", document)
85
+ if isinstance(document.get("choices"), list):
86
+ raw = extract_json(_content_of(document))
87
+
88
+ try:
89
+ return profile, Simulation.model_validate(raw)
90
+ except ValidationError as exc:
91
+ raise ProfileError(_format_validation(exc, prefix=f"invalid response {path}")) from exc
92
+
93
+
94
+ def _warnings(settings, profile=None) -> list[str]:
95
+ warnings: list[str] = []
96
+ if settings.base_url.scheme != "https":
97
+ warnings.append("endpoint is not HTTPS; the API key travels in clear text")
98
+ host = settings.base_url.host or ""
99
+ if host in {"localhost", "127.0.0.1"}:
100
+ warnings.append(f"endpoint is local ({host}); fine for a proxy or test server")
101
+ if settings.temperature > 1.2:
102
+ warnings.append("temperature above 1.2 often breaks the JSON contract")
103
+ if profile is not None and profile.company.lower() in {"acme", "example", "example corp"}:
104
+ warnings.append(f"company {profile.company!r} is generic; add specifics")
105
+ return warnings
106
+
107
+
108
+ @app.command()
109
+ def generate(
110
+ profile: Path = typer.Argument(
111
+ None, metavar="[PROFILE]", help="YAML profile, or - to read JSON from stdin."
112
+ ),
113
+ base_url: str = typer.Option(
114
+ None, "--base-url", "-u", help=f"API root [{ENV_PREFIX}BASE_URL]."
115
+ ),
116
+ model: str = typer.Option(None, "--model", "-m", help=f"Model name [{ENV_PREFIX}MODEL]."),
117
+ api_key: str = typer.Option(
118
+ None, "--api-key", "-k", help=f"Bearer token [{ENV_PREFIX}API_KEY, OPENAI_API_KEY]."
119
+ ),
120
+ provider: str = typer.Option(
121
+ None,
122
+ "--provider",
123
+ "-p",
124
+ help=f"Preset base URL: {', '.join(PROVIDERS)}. --base-url wins.",
125
+ ),
126
+ channel: Channel | None = typer.Option(
127
+ None, "--channel", "-c", help="Force the opening channel."
128
+ ),
129
+ temperature: float = typer.Option(
130
+ None, "--temperature", "-t", min=0.0, max=2.0, help="Sampling temperature."
131
+ ),
132
+ max_tokens: int = typer.Option(None, "--max-tokens", min=256, max=16000),
133
+ timeout: float = typer.Option(None, "--timeout", min=1.0, max=600.0, help="Seconds."),
134
+ retries: int = typer.Option(None, "--retries", min=0, max=8, help="Retry count on 429/5xx."),
135
+ seed: int = typer.Option(None, "--seed", help="Sampling seed, if supported."),
136
+ extra: str = typer.Option(None, "--extra", help="Extra direction for the model."),
137
+ fmt: OutputFormat = typer.Option(OutputFormat.rich, "--format", "-f", help="Output format."),
138
+ out_file: Path = typer.Option(None, "--out", "-o", help="Write output to this file."),
139
+ dry_run: bool = typer.Option(
140
+ False,
141
+ "--dry-run",
142
+ help="Resolve config and print the prompt; call nothing. --out writes the prompt.",
143
+ ),
144
+ response_file: Path = typer.Option(
145
+ None, "--from-file", help="Render a saved response instead of calling the API."
146
+ ),
147
+ sections: str = typer.Option(
148
+ None, "--sections", help="Comma list of: pretext, opening, escalation, red_flags."
149
+ ),
150
+ usage: bool = typer.Option(False, "--usage", help="Print token usage to stderr."),
151
+ no_color: bool = typer.Option(False, "--no-color", help="Disable ANSI color."),
152
+ ) -> None:
153
+ """Generate one simulation from a profile."""
154
+
155
+ out = render.console(no_color=no_color)
156
+ err = render.console(no_color=no_color, stderr=True)
157
+
158
+ selected: tuple[str, ...] = render.SECTIONS
159
+ if sections is not None:
160
+ selected = tuple(part.strip() for part in sections.split(",") if part.strip())
161
+ unknown = [part for part in selected if part not in render.SECTIONS]
162
+ if unknown or not selected:
163
+ err.print(
164
+ f"[bold red]error[/bold red] unknown section(s): {', '.join(unknown) or 'none'}; "
165
+ f"choose from {', '.join(render.SECTIONS)}"
166
+ )
167
+ raise typer.Exit(2)
168
+
169
+ if profile is None and response_file is None:
170
+ err.print("[bold red]error[/bold red] pass a PROFILE or --from-file")
171
+ raise typer.Exit(2)
172
+
173
+ saved = None
174
+ try:
175
+ if response_file is not None:
176
+ saved_profile, saved = _read_response(response_file)
177
+ if profile is None:
178
+ if saved_profile is None:
179
+ raise ProfileError(
180
+ f"response {response_file} has no profile; pass a PROFILE too"
181
+ )
182
+ prof = saved_profile
183
+ if profile is not None:
184
+ prof = _read_profile(profile)
185
+ settings = resolve_settings(
186
+ provider=provider,
187
+ base_url=base_url,
188
+ model=model,
189
+ api_key=api_key or ("unused" if saved is not None else None),
190
+ temperature=temperature,
191
+ timeout=timeout,
192
+ retries=retries,
193
+ max_tokens=max_tokens,
194
+ seed=seed,
195
+ dotenv=Path.cwd() / ".env",
196
+ )
197
+ except PretextError as exc:
198
+ err.print(f"[bold red]error[/bold red] {exc}")
199
+ raise typer.Exit(1) from exc
200
+
201
+ warnings = _warnings(settings, prof)
202
+
203
+ if dry_run:
204
+ err.print("[bold]dry run[/bold] · nothing is sent")
205
+ render.render_settings(settings, err)
206
+ render.render_profile(prof, err)
207
+ render.render_warnings(warnings, err)
208
+ prompt = dump_prompt(prof, channel=channel.value if channel else None, extra=extra)
209
+ if out_file is None:
210
+ err.print()
211
+ typer.echo(prompt)
212
+ else:
213
+ out_file.write_text(prompt + "\n", encoding="utf-8")
214
+ err.print(f"wrote prompt to {out_file}")
215
+ raise typer.Exit(0)
216
+
217
+ render.render_warnings(warnings, err)
218
+
219
+ client = Client(settings)
220
+ if saved is not None:
221
+ simulation = saved
222
+ err.print(f"[dim]replayed {response_file}[/dim]")
223
+ else:
224
+ try:
225
+ with err.status("[cyan]requesting simulation…[/cyan]", spinner="dots"):
226
+ simulation = client.generate(
227
+ prof, channel=channel.value if channel else None, extra=extra
228
+ )
229
+ except PretextError as exc:
230
+ err.print(f"[bold red]error[/bold red] {exc}")
231
+ raise typer.Exit(1) from exc
232
+ except KeyboardInterrupt:
233
+ err.print("[yellow]interrupted[/yellow]")
234
+ raise typer.Exit(130) from None
235
+
236
+ if usage:
237
+ tokens = client.last_usage or {}
238
+ err.print(
239
+ f"[dim]tokens: prompt {tokens.get('prompt_tokens', '?')} · "
240
+ f"completion {tokens.get('completion_tokens', '?')} · "
241
+ f"total {tokens.get('total_tokens', '?')}[/dim]"
242
+ )
243
+
244
+ if fmt is OutputFormat.json:
245
+ body = render.to_json(simulation, prof, sections=selected)
246
+ elif fmt is OutputFormat.markdown:
247
+ body = render.to_markdown(simulation, prof, sections=selected)
248
+ else:
249
+ body = None
250
+
251
+ if out_file is not None:
252
+ if body is None:
253
+ recording = render.console(no_color=True, width=100)
254
+ with recording.capture() as captured:
255
+ render.render_simulation(simulation, prof, recording, sections=selected)
256
+ body = captured.get()
257
+ out_file.write_text(body, encoding="utf-8")
258
+ err.print(f"wrote {out_file}")
259
+ return
260
+
261
+ if body is not None:
262
+ typer.echo(body)
263
+ else:
264
+ render.render_simulation(simulation, prof, out, sections=selected)
265
+
266
+
267
+ @app.command()
268
+ def validate(profile: Path = typer.Argument(..., metavar="PROFILE")) -> None:
269
+ """Check a profile without calling the API."""
270
+
271
+ out = render.console()
272
+ try:
273
+ prof = _read_profile(profile)
274
+ except PretextError as exc:
275
+ render.console(stderr=True).print(f"[bold red]invalid[/bold red] {exc}")
276
+ raise typer.Exit(1) from exc
277
+ render.render_profile(prof, out)
278
+
279
+
280
+ @app.command()
281
+ def example(
282
+ out_file: Path = typer.Option(None, "--out", "-o", help="Write instead of printing."),
283
+ ) -> None:
284
+ """Print the bundled example profile."""
285
+
286
+ text = (
287
+ resources.files("pretext").joinpath("data/example-profile.yaml").read_text(encoding="utf-8")
288
+ )
289
+ if out_file is None:
290
+ typer.echo(text, nl=False)
291
+ else:
292
+ out_file.write_text(text, encoding="utf-8")
293
+ typer.echo(f"wrote {out_file}")
294
+
295
+
296
+ @app.command("config")
297
+ def show_config(
298
+ provider: str = typer.Option(None, "--provider", "-p", help=f"Preset: {', '.join(PROVIDERS)}."),
299
+ base_url: str = typer.Option(None, "--base-url", "-u"),
300
+ model: str = typer.Option(None, "--model", "-m"),
301
+ api_key: str = typer.Option(None, "--api-key", "-k"),
302
+ ) -> None:
303
+ """Show the configuration that would be used, with the key masked."""
304
+
305
+ out = render.console()
306
+ err = render.console(stderr=True)
307
+ try:
308
+ settings = resolve_settings(
309
+ provider=provider,
310
+ base_url=base_url,
311
+ model=model,
312
+ api_key=api_key,
313
+ dotenv=Path.cwd() / ".env",
314
+ )
315
+ except ConfigError as exc:
316
+ err.print(f"[bold red]error[/bold red] {exc}")
317
+ raise typer.Exit(1) from exc
318
+ render.render_settings(settings, out)
319
+ render.render_warnings(_warnings(settings), err)
320
+
321
+
322
+ def main() -> None:
323
+ app()
324
+
325
+
326
+ if __name__ == "__main__":
327
+ main()
328
+
329
+
330
+ @app.command()
331
+ def batch(
332
+ directory: Path = typer.Argument(..., metavar="DIR", help="Directory of YAML profiles."),
333
+ out_dir: Path = typer.Option(..., "--out-dir", "-o", help="Where to write each exercise."),
334
+ fmt: OutputFormat = typer.Option(OutputFormat.markdown, "--format", "-f"),
335
+ sections: str = typer.Option(None, "--sections", help="Comma list of sections to render."),
336
+ extra: str = typer.Option(None, "--extra", help="Extra direction for the model."),
337
+ base_url: str = typer.Option(
338
+ None, "--base-url", "-u", help=f"API root [{ENV_PREFIX}BASE_URL]."
339
+ ),
340
+ model: str = typer.Option(None, "--model", "-m", help=f"Model name [{ENV_PREFIX}MODEL]."),
341
+ api_key: str = typer.Option(
342
+ None, "--api-key", "-k", help=f"Bearer token [{ENV_PREFIX}API_KEY, OPENAI_API_KEY]."
343
+ ),
344
+ provider: str = typer.Option(
345
+ None,
346
+ "--provider",
347
+ "-p",
348
+ help=f"Preset base URL: {', '.join(PROVIDERS)}. --base-url wins.",
349
+ ),
350
+ temperature: float = typer.Option(None, "--temperature", "-t", min=0.0, max=2.0),
351
+ max_tokens: int = typer.Option(None, "--max-tokens", min=256, max=16000),
352
+ timeout: float = typer.Option(None, "--timeout", min=1.0, max=600.0),
353
+ retries: int = typer.Option(None, "--retries", min=0, max=8),
354
+ seed: int = typer.Option(None, "--seed"),
355
+ usage: bool = typer.Option(False, "--usage", help="Print token totals to stderr."),
356
+ no_color: bool = typer.Option(False, "--no-color"),
357
+ ) -> None:
358
+ """Generate one exercise per profile in a directory."""
359
+
360
+ err = render.console(no_color=no_color, stderr=True)
361
+ selected: tuple[str, ...] = render.SECTIONS
362
+ if sections is not None:
363
+ selected = tuple(part.strip() for part in sections.split(",") if part.strip())
364
+ unknown = [part for part in selected if part not in render.SECTIONS]
365
+ if unknown or not selected:
366
+ err.print(f"[bold red]error[/bold red] unknown section(s): {', '.join(unknown)}")
367
+ raise typer.Exit(2)
368
+
369
+ if not directory.is_dir():
370
+ err.print(f"[bold red]error[/bold red] {directory} is not a directory")
371
+ raise typer.Exit(1)
372
+
373
+ profiles = sorted(p for p in directory.glob("*.yaml") if not p.name.startswith("_"))
374
+ if not profiles:
375
+ err.print(f"[bold red]error[/bold red] no *.yaml profiles in {directory}")
376
+ raise typer.Exit(1)
377
+
378
+ try:
379
+ settings = resolve_settings(
380
+ provider=provider,
381
+ base_url=base_url,
382
+ model=model,
383
+ api_key=api_key,
384
+ temperature=temperature,
385
+ timeout=timeout,
386
+ retries=retries,
387
+ max_tokens=max_tokens,
388
+ seed=seed,
389
+ dotenv=Path.cwd() / ".env",
390
+ )
391
+ except PretextError as exc:
392
+ err.print(f"[bold red]error[/bold red] {exc}")
393
+ raise typer.Exit(1) from exc
394
+
395
+ out_dir.mkdir(parents=True, exist_ok=True)
396
+ client = Client(settings)
397
+ suffix = {"markdown": "md", "json": "json", "rich": "txt"}[fmt.value]
398
+ failures: list[tuple[str, str]] = []
399
+ totals = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
400
+
401
+ for index, path in enumerate(profiles, start=1):
402
+ label = f"[cyan]{index}/{len(profiles)}[/cyan] {path.name}"
403
+ try:
404
+ prof = load_profile(path)
405
+ simulation = client.generate(prof, channel=None, extra=extra)
406
+ except PretextError as exc:
407
+ failures.append((path.name, str(exc).splitlines()[0]))
408
+ err.print(f"{label} [red]failed[/red] {failures[-1][1]}")
409
+ continue
410
+
411
+ for key in totals:
412
+ totals[key] += (client.last_usage or {}).get(key, 0)
413
+
414
+ target = out_dir / f"{path.stem}.{suffix}"
415
+ if fmt is OutputFormat.json:
416
+ body = render.to_json(simulation, prof, sections=selected)
417
+ elif fmt is OutputFormat.markdown:
418
+ body = render.to_markdown(simulation, prof, sections=selected)
419
+ else:
420
+ recording = render.console(no_color=True, width=100)
421
+ with recording.capture() as captured:
422
+ render.render_simulation(simulation, prof, recording, sections=selected)
423
+ body = captured.get()
424
+ target.write_text(body, encoding="utf-8")
425
+ err.print(f"{label} [green]ok[/green] {target.name}")
426
+
427
+ err.print()
428
+ err.print(f"wrote {len(profiles) - len(failures)}/{len(profiles)} to {out_dir}")
429
+ if usage:
430
+ err.print(
431
+ f"[dim]tokens: prompt {totals['prompt_tokens']} · "
432
+ f"completion {totals['completion_tokens']} · total {totals['total_tokens']}[/dim]"
433
+ )
434
+ for name, reason in failures:
435
+ err.print(f"[red]{name}[/red] {reason}")
436
+ if failures:
437
+ raise typer.Exit(1)
438
+
439
+
440
+ @app.command()
441
+ def doctor(
442
+ base_url: str = typer.Option(
443
+ None, "--base-url", "-u", help=f"API root [{ENV_PREFIX}BASE_URL]."
444
+ ),
445
+ model: str = typer.Option(None, "--model", "-m", help=f"Model name [{ENV_PREFIX}MODEL]."),
446
+ api_key: str = typer.Option(
447
+ None, "--api-key", "-k", help=f"Bearer token [{ENV_PREFIX}API_KEY, OPENAI_API_KEY]."
448
+ ),
449
+ provider: str = typer.Option(
450
+ None,
451
+ "--provider",
452
+ "-p",
453
+ help=f"Preset base URL: {', '.join(PROVIDERS)}. --base-url wins.",
454
+ ),
455
+ full: bool = typer.Option(False, "--full", help="Also run one real simulation."),
456
+ no_color: bool = typer.Option(False, "--no-color"),
457
+ ) -> None:
458
+ """Diagnose the endpoint before spending a run on it."""
459
+
460
+ out = render.console(no_color=no_color)
461
+ err = render.console(no_color=no_color, stderr=True)
462
+ results: list[tuple[str, str, str]] = []
463
+
464
+ def record(name: str, status: str, detail: str) -> None:
465
+ results.append((name, status, detail))
466
+
467
+ try:
468
+ settings = resolve_settings(
469
+ provider=provider,
470
+ base_url=base_url,
471
+ model=model,
472
+ api_key=api_key,
473
+ dotenv=Path.cwd() / ".env",
474
+ )
475
+ record("configuration", "pass", f"{settings.base_url} · {settings.model}")
476
+ except PretextError as exc:
477
+ record("configuration", "fail", str(exc))
478
+ _render_checks(results, out)
479
+ raise typer.Exit(1) from exc
480
+
481
+ if settings.base_url.scheme != "https":
482
+ record("tls", "warn", "not HTTPS; the key is sent in clear text")
483
+ else:
484
+ record("tls", "pass", "HTTPS")
485
+
486
+ client = Client(settings)
487
+ try:
488
+ models = client.list_models()
489
+ record("endpoint", "pass", "reached /models")
490
+ if not models:
491
+ record("model", "warn", "endpoint lists no models; cannot verify the name")
492
+ elif settings.model in models:
493
+ record("model", "pass", f"{settings.model} is served")
494
+ else:
495
+ near = [m for m in models if m.split("/")[-1] in settings.model.split("/")[-1]][:3]
496
+ hint = f"; did you mean {', '.join(near)}" if near else ""
497
+ record("model", "fail", f"{settings.model} is not in the model list{hint}")
498
+ except PretextError as exc:
499
+ record("endpoint", "fail", str(exc))
500
+
501
+ try:
502
+ parsed = extract_json(client.ping_json())
503
+ record("json mode", "pass", f"returned {', '.join(sorted(parsed)) or 'an empty object'}")
504
+ except PretextError as exc:
505
+ record("json mode", "fail", str(exc))
506
+
507
+ if full:
508
+ probe = Profile(
509
+ name="Test Persona",
510
+ role="Analyst",
511
+ company="Northwind",
512
+ communication_style="Plain and brief",
513
+ )
514
+ try:
515
+ simulation = client.generate(probe)
516
+ record(
517
+ "schema",
518
+ "pass",
519
+ f"{len(simulation.escalation_path)} stages, {len(simulation.red_flags)} flags",
520
+ )
521
+ except PretextError as exc:
522
+ record("schema", "fail", str(exc).splitlines()[0])
523
+
524
+ _render_checks(results, out)
525
+ if any(status == "fail" for _, status, _ in results):
526
+ raise typer.Exit(1)
527
+ if not full:
528
+ err.print("[dim]add --full to test schema adherence too[/dim]")
529
+
530
+
531
+ def _render_checks(results: list[tuple[str, str, str]], out) -> None:
532
+ marks = {
533
+ "pass": "[green]pass[/green]",
534
+ "warn": "[yellow]warn[/yellow]",
535
+ "fail": "[red]fail[/red]",
536
+ }
537
+ table = Table(show_header=True, box=None, header_style="bold")
538
+ table.add_column("check")
539
+ table.add_column("")
540
+ table.add_column("detail", overflow="fold")
541
+ for name, status, detail in results:
542
+ table.add_row(name, marks[status], detail)
543
+ out.print(table)
544
+
545
+
546
+ @app.command()
547
+ def schema(
548
+ everything: bool = typer.Option(False, "--all", help="Include the profile schema too."),
549
+ ) -> None:
550
+ """Print the JSON Schema the model output must satisfy."""
551
+
552
+ payload = {"simulation": Simulation.model_json_schema()}
553
+ if everything:
554
+ payload["profile"] = Profile.model_json_schema()
555
+ typer.echo(json.dumps(payload, indent=2))