Skip to content

Harden the instruction check and the model gateway's spend cap - #2586

Merged
pournasserian merged 14 commits into
v2from
2579-gateway-hardening
Oct 19, 2026
Merged

pournasserian merged 14 commits into
v2from
2579-gateway-hardening

Conversation

@pournasserian

@pournasserian pournasserian commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Closes #2579

What

Hardening from the post-merge review of #2535, #2545 and #2551 (F-CHK #2420).

  • Instruction check (instructions, SEC-03):
    • It reads the fields whole, as an LLM-judged check sends them (CheckContent.AllText): property names as well as values, and an entity's fact metadata, such as a source's reference, as well as its value.
    • It matches on a folded copy of each text:
      • compatibility forms decomposed (NFKD), so fullwidth and mathematical letters become ASCII
      • a decomposition longer than three characters, such as the 18 letters of U+FDFA, kept as written
      • combining marks of every kind dropped, outside the Basic Multilingual Plane too
      • look-alikes mapped to the Latin letter they imitate, after Unicode's confusables (UTS Setup .editorconfig #39): Cyrillic, Greek and Armenian letters, small capitals, and letters with a stroke
      • letters that render blank, such as U+2800 and U+3164, read as spaces
      • invisible characters left out, as before
    • ASCII text folds to itself without copying. Each character of the Basic Multilingual Plane is folded once per process into a fixed table, one slot per character, with no lock and nothing to fill up; a character outside it is folded each time, which costs about as much as reading it.
    • Each folded character remembers where it came from, so a finding quotes and points at the text as it was written.
    • Site phrases are folded the same way. A phrase whose letters are all Latin, accented or stroked ones such as ł or ß included, is matched on the look-alike copy, so its look-alike spellings count; a phrase in another script is matched in that script, in either case. A phrase that folds to nothing is refused as a setting, and an active one matches nothing.
    • A key and its own value point at the same place, so the same instruction in both is one finding.
  • Prices (SEC-04): a profile whose endpoint is not on this machine must set both token prices, or set Free to say it charges nothing. Otherwise every call costs $0 and no spend cap refuses one. The host stops at startup with what to change.
  • Missing usage: an answer without token counts keeps its reserved cost instead of settling at $0.
  • Who pays: ModelRequest.ChargedTo becomes OnBehalfOf, the person or agent whose work an engine asks the model for. An agent's call counts against its cap and a person's against none. The gateway refuses (model_call_unaccounted), recording and sending nothing:
    • a call with no initiator
    • an engine call that names no one
    • a person's or an agent's call that names someone else
  • The check run asks on behalf of whoever opened the set (CheckInput.OnBehalfOf), so LLM-judged checks judge a person's set uncapped and an agent's against its cap. With the old ChargedTo, an engine judging a person's set would have been refused.
  • Purged calls: completing a call writes its outcome, tokens and cost, but no response or error into a record that was purged while it waited, so the purge holds. Models__PromptRetention must be longer than the longest profile timeout.
  • CONTRIBUTING.md: prices, Free, who pays, retention, and what the instruction check reads.

Why

The review found that the SEC-03 check could be bypassed with a key or a look-alike letter. It also found that the SEC-04 spend cap was silently off when prices were left out, that a missing usage block zeroed a call's cost, that system and initiator-less calls went uncapped, and that a purge could be undone by a call in flight.

Choices worth a look:

  • A hand-picked look-alike table, not the whole confusables file. It covers the letters that pass for Latin in the scripts most used for this, and NFKD covers fullwidth and mathematical forms. The check only matches English instruction phrases, so folding cannot flag ordinary Russian or Greek copy; a test pins that. A look-alike outside the table can still get through; the table grows as one is found.
  • A phrase in another script is matched in that script only. Matching it on the look-alike copy too flagged English inside words ("тор" in "stop"), so a Cyrillic phrase written with one Latin twin letter is not caught; that is no worse than before this PR.
  • A local endpoint needs no prices, as the issue says, even a proxy on this machine that forwards to a paid provider. Its administrator sets prices for it when it does.
  • Prices or Free, not a default price. A made-up price would make caps refuse or allow at random. An endpoint on this machine, such as Ollama, needs neither.
  • A purged in-flight call still settles its outcome and cost, so spend stays right; only its text stays gone.
  • Not in this PR: the recurring retention purge as SystemInitiator.Retention (its own issue), and a spend lock across processes (Add the model gateway with profiles, recording, spend caps and the prompt store #2545 records that it works within one).

Tests

Unit:

  • Instruction check conformance:
    • fails on an instruction in a property name, in a fact's source reference, and in fullwidth, Cyrillic (including г for r), Greek, mathematical bold, small capital, stroked, accented, enclosed and supplementary-mark spellings, and with a blank letter for a space
    • passes ordinary Russian and Greek copy and an innocent key such as "Ignore list"
    • refuses a phrase that folds to nothing, and an active one matches nothing
  • Two million U+FDFA characters are checked in under three seconds, and a field of 70,000 different characters outside the Basic Multilingual Plane followed by a million CJK characters in under five, so no field slows a later one.
  • A Cyrillic phrase whose letters look Latin does not match English text, and the same instruction in a key and its value is one finding.
  • Findings in a look-alike spelling and in a property name quote the text as written, with pointers into the value and the key.
  • Gateway:
    • an answer without token counts costs its reservation
    • an engine call is refused when it names no one, counts against the agent it names, and runs uncapped for a person
    • a person naming an agent, and a call with no initiator, are refused, and nothing is sent
    • an engine call for an agent at its cap, or for an unknown one, is refused
  • CheckRunner passes whose work it judges to the model.

Integration, on SQLite and PostgreSQL where a database is involved:

  • A remote profile without prices stops the host, a Free one and a local one do not, and a retention no longer than a profile's timeout stops it.
  • A call purged while its provider is still answering ends succeeded, with its cost and no prompt or response.
  • The existing validation test lists the new price failure.

The full suite ran locally against a postgres:17 container as well as SQLite, and markdownlint passes. No new package and no migration.

Checklist

  • Linked to an issue a maintainer has confirmed
  • Targets v2
  • Tests or docs updated where behaviour changed

@pournasserian pournasserian added this to the M1 Governance core milestone Oct 19, 2026
@pournasserian pournasserian added type:feature New capability v2 FluentCMS v2 (v2 branch) area:checks labels Oct 19, 2026
@pournasserian
pournasserian merged commit f855129 into v2 Oct 19, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2579-gateway-hardening branch October 19, 2026 11:03
pournasserian added a commit that referenced this pull request Oct 19, 2026
…fusal tests (#2617)

Closes #2600

## What

Hardening from the review of #2558 (`SystemInitiator`, ADR-0012):
low-severity gaps, none exploitable today, that future engine or plugin
code could have used to act beyond what ADR-0012 allows.

- **Reset links** (`IdentityPasswordReset.IssueAsync`): an agent or an
engine is refused with `user_people_only`, as managing users already is.
No token is issued and nothing is logged.
- **Only Core's engines exist**:
- `SystemInitiator`'s constructor is internal to Core. Code gets an
engine from the named ones (`Checks`, `Retention`).
- `InitiatorKinds.RestoreSystem` reads a stored row back only as one of
the product's engines, with its own id; any other component name is
refused as a fault.
- **Engines are an allow-list for jobs**:
- `EngineJobs` in Core lists each job handler that may run as an engine,
with that engine. Today the check run is listed as `checks`.
- The job worker asks it before it runs a handler, and fails the job
with `job_engine_not_allowed`, without running it, when:
    - its handler names an engine it is not listed for, or
- an engine enqueued the job and its handler names no engine. Code
running inside an engine's scope, such as a check type, would otherwise
pass the engine on through a job.
- A listed handler runs as its own engine whoever enqueued its job. A
handler that names no engine runs as whoever enqueued its job when that
is a person or an agent, as before.
- **Docs**: ADR-0012 states the allow-list, that a plugin's job handler
never runs as an engine (EXT-03), and that check types, a plugin's
included, run inside the check engine's scope, bounded by the engine's
limits. It also corrects the list of what only a person does.
CONTRIBUTING.md says the same for job handlers.
- **Tests for refusals that had none**, with an engine or an agent as
initiator:
  - grouping an operation into a session's set, at the directory level
  - disabling and enabling a user
  - revoking an agent credential
  - deactivating a check
  - issuing a reset link
- an audit row and a job row stored with another engine's id, and a row
naming no engine of the product, read back

## Why

The review of #2558 found three gaps:

- `IssueAsync` did not check its caller.
- The worker ran a handler as any `SystemInitiator` its `RunAs`
returned, and anyone could make one.
- Several refusals had no test.

Its first review here found two more: code running as an engine could
pass the engine on through a job it enqueued, and reading a row could
make up an engine. Nothing exploits these today, since only Core code
runs, but ADR-0012's "no caller can borrow an engine's permissions"
should hold for the code that comes later, plugins included.

Choices worth a look:

- **Keyed by the handler's type.** A plugin cannot make an object of
Core's internal `CheckRunJob`, so it cannot pass as it. Code in the same
process could still get around this through reflection; the allow-list
stops a handler from claiming an engine, not code set on misuse.
Per-plugin permissions come with the plugin SDK (EXT-03, M3).
- **Fail, don't fall back.** A job refused this way fails rather than
running as its enqueuer or as no one, so the mistake shows on the job
instead of running with different permissions than its author expected.
- **Check types still run in the engine's scope.** Running them as the
agent whose set they judge would record the agent for what the engine
does, which ADR-0012 rejects. The engine's own limits bound them.
- **Later**: when routing publishes as an engine (F-RTE), jobs that
publishing enqueues for handlers outside Core, such as webhook
deliveries, would be refused by this rule; #2632 tracks how they should
run.
- **Merge after #2586.** ADR-0012's model-spend line describes
`OnBehalfOf` from #2586.

## Tests

Unit:

- `EngineJobs`:
  - the check run runs as `checks`
  - a handler that names no engine runs as a person's or an agent's job
  - an unlisted handler that claims an engine is refused
- a job an engine enqueued for a handler that names no engine is refused
- `SystemInitiator`: one stable id per component, read back only for the
product's engines; no public constructor.

Integration, on SQLite and PostgreSQL:

- A plugin handler that claims `checks` fails its job with
`job_engine_not_allowed` and never runs.
- A probe check type running as the check engine during a submission is
refused a reset link. The job it enqueues for an unlisted handler fails
unrun, and an agent is refused a reset link too. Nothing is logged as
issued.
- The refusals and mismatched rows listed above.
- The check run still runs as the engine (`ChangeSetChecksTests`).

The full suite ran locally against a `postgres:17` container as well as
SQLite, and markdownlint passes. No new package and no migration.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:feature New capability v2 FluentCMS v2 (v2 branch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Harden the instruction check and the model gateway's spend cap

1 participant