Threat model #

What this tool is trusted with, what it refuses to be trusted with, and what it does not defend against.

Written the way a review would want it: assets first, then a boundary, then the threats in the order they are likely to matter, each with the control that answers it and a pointer to the test that holds the control in place.

What this is #

A command-line tool. It reads three files, evaluates them at one instant, writes reports, and exits. It listens on no port, has no database, and holds no state between runs. Optionally — and only when asked on the command line — it POSTs telemetry to an OTLP collector.

That shape removes most of the attack surface a reviewer looks for first. There is no session, no authentication, no multi-tenancy, and no request handling. The threats that remain are about data flow: what goes into a window, what comes out in a report, and where it is sent.

Assets #

Asset Why it matters
Prompt and completion text The user's data. Regulated in most contexts, embarrassing in all of them.
Provider credentials An API key in an attribute bag reaches every reader of every report.
The verdict itself A gate that can be made to say "held" is worse than no gate.
Cost figures Wrong numbers here become wrong budgets and wrong capacity plans.
The CI runner The process that runs the gate has a token and a checkout.

Trust boundary #

  untrusted ─────────────────────────────────┐   trusted
                                             
  window file  ─────────────────────────────▶│  Span validation (pydantic, extra=forbid)
  objectives   ─────────────────────────────▶│  Schema check, unknown keys refused
  price book   ─────────────────────────────▶│  Schema check, dated, content-addressed
  environment  ─────────────────────────────▶│  Settings, unknown names refused
                                             
                                               evaluate ─▶ report
                                             
                                             ├─▶ stdout / files      (redacted)
                                             └─▶ OTLP collector      (opt-in only)

Everything to the left is written by something else — an agent, a proxy, a previous run, a contributor's editor. None of it is trusted to be well-formed, bounded, or honest.


T1 — Prompt or completion text leaks into telemetry #

The realistic version. Not a bug in a redactor. An engineer debugging a bad answer puts the question into attributes["query"] because the field is there and the debugging is hard, and six months later that field is in a metrics backend three teams can read.

Control — structural. The span model has no field for prompt or completion text, and extra="forbid" rejects an attempt to add one at validation. There is no configuration under which this tool exports a prompt, because there is nowhere to put one. See ADR-001.

Control — defence in depth. Attributes are capped at 32 keys and 1024 bytes each, so the bag cannot become a transcript by accident.

Residual risk. Somebody can still put a short prompt fragment into an attribute value deliberately. The cap makes it visible in review rather than invisible in production. Accepted.

Tested by tests/unit/test_span.py, tests/security/test_no_leaks.py.


T2 — A credential arrives through the attribute bag and is exported #

The realistic version. A base URL with a key in the query string; an Authorization header attached during an incident and never removed; a connection string used as a service name.

Control. Redaction over the assembled document, once, immediately before it is written — never over the inputs. This is ADR-009, and it is here because the second repository in this series learned that a pass over inputs leaves everything derived from them still carrying what the pass removed.

Two mechanisms: a set of sensitive attribute names whose values are always removed regardless of shape (authorization, token, secret, cookie, password, and their separator-split variants), and a set of vendor-anchored patterns — OpenAI, Anthropic, Google, AWS, GitHub, Slack, bearer tokens, JWTs, URL credentials, PEM private-key headers.

The placeholder is fixed-length. A placeholder that echoed the length of what it replaced would leak it.

Residual risk. Patterns are anchored on vendor prefixes precisely so they do not fire on ordinary text, which means a credential in a format nobody has seen passes through. This is why the structural control in T1 is the primary one and this is the second line.

Tested by tests/security/test_no_leaks.py, including a test that fails if the redaction pass is deleted.


T3 — Telemetry is sent somewhere nobody intended #

The realistic version. An LLMOPS_EXPORT__OTLP_ENDPOINT set in a CI job that nobody reads, and telemetry going to a collector that was decommissioned or was never the right one.

Control. build_exporter refuses to construct a networked exporter unless --allow-network was passed. Not "declines to send" — the object does not exist in the process. Nothing is exported by default.

Residual risk. An operator who passes --allow-network and an endpoint gets what they asked for. Certificate validation is Python's default; there is no option to disable it.

Tested by tests/integration/test_export.py, tests/security/test_no_leaks.py.


T4 — A hostile window file #

The realistic version. A window is a file. It can be truncated, corrupted, enormous, or built to be all three.

Attack Control
Truncated mid-line One JSON document per line; the reader counts unreadable lines and the report states the count
A line of 200 MB MAX_LINE_BYTES (256 KB) — a longer line is not a span, it is a different kind of file
A 40 GB window MAX_WINDOW_BYTES (256 MB), checked before reading
A 200 KB gzip that expands to 40 GB The same limit applied to the decompressed stream, not only to the file on disk
Spans claiming impossible values Validators: no naive timestamps, no cached tokens exceeding input tokens, no error type on a successful span
A window from a newer schema Refused by version, with the version named

The decompression bomb is the one worth calling out. Accepting .gz input means accepting it, and checking only stat() would let one through. Without the stream guard the runner is killed by the OOM killer, which reaches an operator as "the gate is flaky" rather than as an attack.

Residual risk. A window can still contain plausible but false spans. This tool cannot detect a lying producer; see T6.

Tested by tests/integration/test_windows.py, tests/unit/test_span.py.


T5 — The gate is made to pass #

The realistic version. Nobody edits the tool. Somebody edits the objectives file, or the pipeline stops running the step, or a rule silently stops being evaluated and the summary reads as if it held.

Attack Control
Lower a target in a PR Objectives are a reviewed file; the report carries their digest
Delete the price book so the spend objective is skipped Missing price book with a spend objective is exit 3, never a skip
A rule that cannot be evaluated Reported as not evaluated, in its own section, never as passing
Under-sampled window Reported with the sample count, never as passing
The gate step is removed Outside this tool's control — a branch protection concern

"Not evaluated" and "passed" being different is the single most important property of the reports, and it is why every renderer has a separate section for it — JSON, JUnit (skipped, not a passing case) and Markdown alike.

Tested by tests/meta/test_the_gate_can_fail.py, which lowers targets, corrupts windows and deletes price books and asserts the specific failure each should produce.


T6 — The telemetry is wrong, not malformed #

Out of scope, and stated so rather than left implied. If the producer reports half the calls it made, this tool computes a correct verdict about half a platform. There is no cryptographic provenance on a span and no attempt at one.

What the tool does do is refuse to pretend: unreadable lines are counted and declared above the numbers, spend is reconciled against the provider's own reported_cost_usd where the producer supplies it, and a systematic divergence between computed and reported cost is a finding rather than a rounding note.


T7 — Supply chain #

Concern Control
Dependency count Four runtime dependencies: pydantic, pydantic-settings, structlog, pyyaml
Transitive drift uv.lock, committed; CI installs with --locked
Known vulnerabilities pip-audit --strict --no-deps over the exported lock, in CI
Static analysis bandit over src/, and CodeQL on push
Secrets in history gitleaks over the full history, not only the diff
An OTLP SDK's transitive tree Avoided: the exporter is stdlib urllib and under a hundred lines

Exceptions to the audit are recorded in security/audit-exceptions.md with a reason and a date, not as a bare ignore list.


T8 — The CI runner #

The gate runs in a job that has a checkout and a token. This tool executes no subprocess, evaluates no expression from a file, and imports nothing named by configuration. The objectives file is YAML parsed with yaml.safe_load; the price book likewise.

Workflow permissions are least-privilege per job, and the container image runs as a non-root user with no build toolchain in the final layer.


Not defended against #

Stated plainly, because a threat model that claims to cover everything is one nobody can act on.

Reporting #

Security issues: see SECURITY.md. Please do not open a public issue for a vulnerability.