Skip to content

Harden the system initiator: reset links, an engine allow-list and refusal tests - #2617

Merged
pournasserian merged 11 commits into
v2from
2600-system-initiator-hardening
Oct 19, 2026
Merged

pournasserian merged 11 commits into
v2from
2600-system-initiator-hardening

Conversation

@pournasserian

@pournasserian pournasserian commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

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; Run jobs an engine enqueues for handlers outside Core without passing the engine on #2632 tracks how they should run.
  • Merge after Harden the instruction check and the model gateway's spend cap #2586. ADR-0012's model-spend line describes OnBehalfOf from Harden the instruction check and the model gateway's spend cap #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

  • 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 Checks (CHK) labels Oct 19, 2026
@pournasserian
pournasserian merged commit 61c8610 into v2 Oct 19, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2600-system-initiator-hardening branch October 19, 2026 11:39
pournasserian added a commit that referenced this pull request Oct 19, 2026
pournasserian added a commit that referenced this pull request Oct 19, 2026
Closes #2618

## What

The recurring retention purge of stored prompts (AUD-04), from the
post-merge review of #2545: until now nothing purged them unless a
person called `IModelCallLog.PurgeAsync`, and
`SystemInitiator.Retention` was unused.

- **`retention.purge` job** (`RetentionPurgeJob`, Core):
- It runs as `SystemInitiator.Retention` and is listed for it in
`EngineJobs` (#2617).
- It runs only as the retention engine; a run as anyone else fails and
purges nothing.
- It first schedules its next run a day later, so a run that fails or
dies after that still leaves one coming. A run whose every attempt fails
before its scheduling commits leaves none until a host restarts; #2652
follows that up.
- It then calls `PurgeAsync` batch after batch until a batch clears
nothing, each batch in a scope of its own so a long backlog does not
pile up in one context, reporting progress after each.
- Each cleared call keeps its `model_call.purged` event, logged as the
retention engine. A purge the engine may not make fails the run with its
reason.
- **A run is always scheduled** (`IRetentionSchedule`):
- When a host starts, after the database is migrated, it enqueues a run
as the retention engine if none is queued (`RetentionStartup`). A
database the host cannot reach yet only delays that to the next start.
- A run enqueues its successor before it purges, unless another run is
queued. So a running run always leaves one queued, and the schedule is
never empty.
- `PurgeAsync`'s comment says it clears a batch at a time.
- CONTRIBUTING.md: the retention purge.

## Why

AUD-04 sets a retention for stored prompts and responses. A retention
that a person must remember to apply is not enforced, so the product
applies it itself, as the engine ADR-0012 named for it.

Choices worth a look:

- **Daily, not configurable.** It is well inside any retention of days,
and a setting can come when someone needs one.
- **Scheduled from the job and from startup**, rather than a timer in
the host, so the schedule lives in the job queue (ADR-0010) and survives
restarts, and any host can run the purge.
- **Enqueued as the retention engine** through a scope only Core opens,
so the job records the engine as the one that asked for it.
- **Never empty rather than exactly one.** Without a unique slot in the
job queue, two runs that start together can each see the other and must
not both skip, or the schedule ends. Both enqueue, the purge is safe to
repeat, and the next day's runs fold the two chains back into one.
- **Builds on #2617** for `EngineJobs`, now in v2.

## Tests

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

- A started host's worker runs the purge as the retention engine. The
expired call is cleared, `model_call.purged` names the engine, and one
run is left queued a day later. A second host that starts then enqueues
none. The test waits for the run to succeed, and the first host's own
run ends before the expired call goes in.
- A run purges 1,101 calls past the retention across two batches,
reporting 1,000 then 1,101. It logs `model_call.purged` for each as the
retention engine, and schedules the next run a day later as that engine.
- A run as the retention engine that finds one already queued schedules
none.
- A purge that fails fails the run with its reason, and the next run is
still scheduled.
- A run made as another engine fails with `model_purge_people_only`, and
purges and schedules nothing.
- Two runs at once leave at least one run queued.
- `EngineJobs` lists the purge for the retention engine (unit).

The full suite ran locally against a `postgres:17` container as well as
SQLite, and markdownlint passes. No new package and no migration. After
v2's webhook job, the model test scenario gives the job handlers a
secret protector stub, since the retention tests resolve them together
as the worker does. A started host now runs a retention job at once, so
a test that reads the SQLite file while its host runs, as the webhook
tests do, reads it without asking for sole access.

## 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

area:checks Checks (CHK) type:feature New capability v2 FluentCMS v2 (v2 branch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Harden the system initiator: reset links, an engine allow-list and refusal tests

1 participant