Skip to content

Config loads run one snapshot of the directory; sandbox workers get its bytes (BACKLOG #2596, steps 1 partly, 2, 5) - #2556

Open
wshallwshall wants to merge 18 commits into
mainfrom
b437-2596-snapshot-loader
Open

wshallwshall wants to merge 18 commits into
mainfrom
b437-2596-snapshot-loader

Conversation

@wshallwshall

@wshallwshall wshallwshall commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

BACKLOG #2596 (steps 1 partly, 2, 5). Batch 437. Source branch b437-2596-snapshot-loader, head 02c1bc98b57bf453694e96db577ff07bdc432750. Own PR because it changes a security control (what an approval binds, and what a sandbox worker runs).

What changed

  • messagefoundry/config/snapshot.py: ConfigSnapshot / read_config_snapshot read the config directory once, after the trust check. __pycache__ is never read.
  • load_config compiles every module and _ helper from the snapshot bytes (compile+exec, not spec_from_file_location). Code sets and connections.toml parse from the same bytes. The snapshot rides on the graph as Registry.config_snapshot.
  • Fingerprint scheme mefor-cfg-fp:v3 folds the snapshot. A code-set table matches by lower-case suffix (codesets/x.CSV is hashed on POSIX; #3535 item 1), and the resolved-path dedup is gone, so a symlinked _ helper changes the digest (#3535 item 2).
  • engine.reload_detail reads one strict snapshot, checks the approval against it, loads it, and records its digest. The post-load re-checks and _require_unchanged_code are gone.
  • Sandbox: the boot frame carries the snapshot (each code-set table once) instead of config_dir; the worker runs load_snapshot and reads no files. A session with no snapshot reads it before the child starts. The child never refuses on its own (ADR 0087).
  • Snapshot names are checked as plain names on the platform (Windows refuses \ and drive names); load diagnostics read the bytes that ran.
  • ADR 0041 D1: dated amendment for the scheme change. No ADR number allocated.

Not done (remainder stays on the row)

  • Step 1's value file under [environments].base_dir is still read by the serve provider, outside the snapshot.

QA

QA -- korus roles/BUILDER.md step 11
Level: xhigh, from the brief. Tag: xhigh effort → 10 inline angles → dedup (no verify) → sweep → ≤15 findings (forks af5659bbe00686cc8 round-9063c00789, ad0153f88547775c3 round-a271b7cefa); review_tag.py exit 0 both rounds.
Rounds: 2. Findings: 13 confirmed and fixed; 7 rejected; 9 open, filed nowhere yet: 6 in the PR scope, 3 outside it.

The review covers head e33f5fbc1, which holds the round-1 build (Builder 1) plus the review round's 8 commits (Builder 2). Each fix has a regression test in tests/test_config_snapshot.py.

Rejected (quality or bounded): environments bytes held in memory; a file deleted mid-read drops from the snapshot (the one that runs is the one hashed); directory listed twice; validate_config duplicating trust-then-read; fingerprint branches kept for one-argument test fakes.

Round 3: semgrep mf-no-eval-exec cleared (commits 1671d17, d1cc2bc, 02c1bc9)

The one literal exec(code, module.__dict__) the branch added (in _HelperImporter.run) is replaced by _SnapshotLoader(importlib.abc.InspectLoader): config modules run through spec.loader.exec_module, as on origin/main, with the code object compiled from the snapshot bytes. Config modules are code-first by design (CLAUDE.md section 1); only where the bytes come from changed. No nosemgrep, no edit to .semgrep/ or the workflow. The loader serves one code object then drops the bytes (runpy and importlib.reload stay refused), serves no source, has no bytecode methods, and keeps the compile-wall error wording. Semgrep 1.172.0 run locally with CI's command: red reproduced on e33f5fb, 0 findings on 02c1bc9.

QA -- korus roles/BUILDER.md step 11
Level: xhigh, from the brief. Tag: xhigh effort → 10 inline angles → dedup (no verify) → sweep → ≤15 findings (forks afa39985f5aea59b3 round-b5ab9b6663, a9aab16a3f799e4d9 round-bbbd0862df); review_tag.py exit 0 both rounds (korus origin/main 20af3e604237db7293729b7048dae3657b738b56, extracted by git show and run by path from the engine worktree).
Rounds: 2. Findings: 8 confirmed and fixed (compile-wall misclassification, linecache stale source, docstring claim, wrong spec comment, reload test guarding nothing, runpy re-run opened, byte lookup moved outside the try, weak compile-test assertion); 3 rejected (dead guard in run kept because it narrows the type for mypy and an assert would trip bandit; SAST visibility, since an exemption is the owner's call per the brief, and DANGEROUS-FUNCTIONALITY section 2 still names the site; linecache entry retention, resolved by the one-shot loader); 1 open.

This round reviewed the delta e33f5fb..02c1bc9; rounds 1 and 2 above cover everything up to e33f5fb. Round-3 checks: ruff, mypy four legs, 67 related test files (2422 passed, 32 skipped), pre-commit including bandit. Full suite not finished locally.

Open findings (for the Lander to file; two change behaviour)

In scope:

  1. Behaviour change (fail-closed): an unreadable _ helper that no module imports now refuses the whole load, where before it only left the fingerprint partial.
  2. Behaviour change (audit classification): on an approved release the trust check now runs before the approval digest. A tamper that also loosens permissions is still refused, but it is audited as an invalid config rather than digest_mismatch; the test pinning the old classification was removed in round 1. Manager reading: the refusal is unchanged and the earlier check is the stronger one, so no control is weakened; only the audit label moves. Medium confidence; a ruling on whether the label must be kept is the owner's.
  3. Config modules get no loader, so pkgutil.get_data and importlib.resources stop working for config code.
  4. No bytecode cache: every load recompiles every module.
  5. Base64 overhead for modules in the boot frame.
  6. macOS sidecar case matching.

Outside scope:
7. capture_start_provenance still re-reads the directory instead of using the snapshot that ran (#2597 area).
8. The dry-run config_reload_check row in api/app.py re-reads the directory too.
9. The step-1 value file (stays on the row).
10. (Round 3) module.__spec__.cached still resolves to a __pycache__ path while __cached__ is None (already true at e33f5fb). Nothing reads it today, but Python 3.15 reads spec.cached. Finding 3 (pkgutil.get_data) was not restored: a snapshot-only get_data would serve a subset of what disk did.

Checks

  • Ran: ruff check + format, mypy all four legs (final tree); targeted pytest of about 1,050 tests (loader, code-set, fingerprint, sandbox, codec, parity, dual control, start alert, passthrough, reference sets, config snapshot), all pass; after round 2 the nine affected suites again, 594 passed.
  • Full suite on Windows once, mid-edit: 16 failed, 51,882 passed. 13 fixed or flaky-and-pass-on-rerun; test_gate_installed_parity and test_installed_coord_hooks x2 are installed-hook drift on the Builder's machine, not this change. Not re-run in full after the fixes.
  • Not run: Linux, Postgres, SQL Server legs.
  • Legs to read: Linux test leg (test_a_posix_module_named_with_a_backslash_loads runs only there; codesets/x.CSV discriminates only there; the symlink digest test may skip on Windows). Postgres and SQL Server legs (test_passthrough, test_reference_sets). windows-service-smoke.

Proposed vault banner (the Lander's to flip)

PARTIAL -- steps 2 and 5 MET, step 1 MET EXCEPT THE VALUE FILE, step 3 MET before (engine PR 2239), step 4 read and holds. Step 1: load_config reads one ConfigSnapshot after the trust check and compiles every module and helper from it (no pycache consulted); code sets and connections.toml parse from it; fingerprint scheme v3 folds it; a reload checks an approval against the snapshot it loads. Remaining: the value file under [environments].base_dir is not in the snapshot. Step 2: the boot frame carries the snapshot and the worker never reads the directory. Step 5: probe tests in tests/test_config_snapshot.py, shown failing on unfixed origin/main. A second code-review round fixed 13 findings. Also closes #3535 items 1 and 2.

Merge conflicts expected

Open PRs 2539 and 2532 touch wiring.py / engine.py; PR 2567 (#3234, same batch) touches wiring.py outside the loader region.

RetriggerView in GreptileConfidence Score: 5/5

The reviewed changes appear safe to merge; no additional actionable defects were found.

Summary

Config loads now compile and parse one captured set of bytes. Reload approvals and reload fingerprints use that snapshot, and sandbox workers receive its bytes instead of reopening the directory.

  • Config loads run from one snapshot of the files they read.
  • Approved reloads check and load the same config snapshot.
  • Sandbox workers run the config snapshot sent by the engine.

Already acknowledged by wshallwshall, and not reported again:

  • Unreadable unused helpers now refuse loads; trust checks can change the audit label for tampering.
  • Config modules have no loader; loads recompile modules; boot frames add base64 overhead; macOS sidecar case matching remains open.
  • Startup provenance and the API dry-run audit can reread the directory. The external environment value file remains outside the snapshot.
  • Keeping environment bytes in memory, dropping files deleted during reading, listing directories twice, duplicating the validator’s trust/read steps, and retaining branches for one-argument test fakes were explicitly rejected or bounded.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Config directory] --> B[Trust check]
  B --> C[Read ConfigSnapshot]
  C --> D[Check approval when required]
  D --> E[Compile modules and parse data]
  E --> F[Registry carries snapshot]
  F --> G[Reload fingerprint from snapshot]
  F --> H[Encode sandbox boot frame]
  H --> I[Worker loads supplied bytes]
Loading

Reviews (1) · Last reviewed commit: "A vanished directory stays missing, and ..." · Reviewed by Greptile

A load read the config directory several times: Python's source loader
read each module and preferred a matching __pycache__ file to it, the
code-set and connections.toml loaders read their own files, and the
digest read everything again. A planted cache file ran under an
unchanged fingerprint.

Now load_config reads the directory once, after the trust check, into a
ConfigSnapshot (config/snapshot.py). Every module and _ helper is
compiled by the loader from the snapshot bytes, code sets and
connections.toml are parsed from them, and the graph carries the
snapshot. The fingerprint folds the same snapshot (scheme v3: code-set
tables matched by lower-case suffix, so codesets/x.CSV is hashed on
POSIX; each path hashed, so a linked helper changes the digest).

A reload reads one strict snapshot, checks a dual-control approval
against it, loads it, and records its digest, so the checked, run and
recorded bytes are one set. The post-load re-checks that refused an
edit after it ran are gone with the edit's effect.

ADR 0041 D1 gains an amendment. The value file under
[environments].base_dir is still not in the snapshot.

BACKLOG #2596 (step 1, partly).
…irectory

The boot frame carried config_dir, so each worker, and each respawn
after a crash or a wall-cap kill, loaded whatever was on disk then,
with no reload and no audit row.

The boot frame now carries the snapshot the engine's graph was loaded
from (files as base64 segments, paths checked against the closed set a
snapshot may hold), and the worker loads it with load_snapshot, which
reads nothing from disk. RegistryRunner hands each session
registry.config_snapshot; a session given none reads config_dir once,
under the load's trust check, and every respawn reuses those bytes.
The child no longer refuses on its own: it simply runs the parent's
verified bytes, the shape ADR 0087 allows.

BACKLOG #2596 (step 2).
…t loader

tests/test_config_snapshot.py: a planted timestamp-valid __pycache__
file does not run (with a control proving the ordinary source loader
would run it), the loader never opens the cache folder, a helper runs
snapshot bytes, a respawned sandbox worker runs the engine's bytes
after an on-disk edit (control: a session with no snapshot is refused),
the boot frame carries the snapshot and no directory, codesets/x.CSV is
hashed, and a linked _ helper changes the digest. Also small /simplify
cleanups (one covered-only rule, one suffix rule, a direct import).

BACKLOG #2596 (step 5).

Proposed PR title:
Config loads run one snapshot of the directory; sandbox workers get its bytes (BACKLOG #2596, steps 1 partly, 2, 5)

Proposed vault banner:
PARTIAL -- steps 2 and 5 MET, step 1 MET EXCEPT THE VALUE FILE, step 3
MET before (engine PR 2239), step 4 read and holds. Step 1: load_config
reads one ConfigSnapshot after the trust check and compiles every
module and helper from it (no __pycache__ consulted); code sets and
connections.toml parse from it; fingerprint scheme v3 folds it; a
reload checks an approval against the snapshot it loads (commit
9ded29d). Remaining: the value file under [environments].base_dir is
not in the snapshot. Step 2: the boot frame carries the snapshot and the
worker never reads the directory (79e9b8e). Step 5: probe tests in
tests/test_config_snapshot.py, shown failing on unfixed origin/main.
@github-actions github-actions Bot added the ci-red A required check went red. Attribute it before retrying. label Oct 27, 2026
The engine's reload now calls load_config(path, snapshot=...). Two fakes
patched over engine.load_config still took one positional argument, so
their reload raised TypeError: test_passthrough's _patch_load_config and
test_reference_sets' unsupporting-backend reload. Both now accept and
ignore keywords.

BACKLOG #2596
Four suites still named round one's old seams and failed on the branch:
two fingerprint fakes took no snapshot keyword; two validate_config
tests patched load_connections_file, which a load no longer calls (it is
load_connections_data now); the path-join census still graded the gone
fingerprint._require_listable and load_config rows and lacked the
snapshot reader and load_snapshot; and the from-None census named
load_policy where the raise moved to parse_policy.

BACKLOG #2596
Two review findings on the snapshot's names. A name the closed-set check
refused raised a bare ValueError out of every load: on POSIX a file such
as codesets/a\b.csv crashed serve start and made validate_config raise.
The check now asks whether each part is a plain name on this platform:
POSIX accepts a backslash or a colon, which its listing can return, and
Windows refuses a backslash or a drive (C:x.py), which joined to the
directory would select another file's bytes from a boot frame.

Modules and code-set tables ran in string order, case-sensitive, where
the loader once sorted paths: without case on Windows. They sort by path
again, so load order and the file a duplicate refusal names hold.

BACKLOG #2596
The frame carried every table twice: as base64 file bytes in the
snapshot and as the engine's parsed tables. That halved the table volume
the 64 MiB frame cap admits, so a sandboxed inbound could fail to boot
where mode=off served. When the engine publishes its tables, the frame
now leaves the codesets/ files out, and load_snapshot takes the tables
in place of parsing them, before any module runs. With none published,
the files go and the worker parses them, as before.

BACKLOG #2596
A session given no snapshot read its directory after Popen, inside a
handler that killed the child only for three exception types, so any
other fault left a live child, job handle and drain threads. The boot
frame is now built before the child exists.

A worker runs the snapshot it is sent and never reads the directory, so
the refusal text and the sandbox_worker_refused alert no longer name an
edit on disk as the cause, or a reload as the remedy for a graph that
differs by name. The alert tests now send the worker edited bytes to
provoke a content-only refusal, since a disk edit no longer reaches it.

BACKLOG #2596
The identifier a load failure may name is vouched for by the source
line that raised it. That line was read from the file on disk, which may
hold other bytes by then, and a sandbox worker read the directory on
this path. The loader now hands the diagnostic the snapshot's bytes for
any file it compiled.

BACKLOG #2596
validate_config counted an unreadable codesets_map.py, a module, as a
table, so an empty graph drew a derived diagnostic beside its cause. It
now matches the codesets folder and its files only.

config_code_digest(path) read the whole directory strictly, so an
unreadable value file took away a digest of modules alone. It reads
non-strictly and refuses only for a module it could not read, naming
it. The reload passes its snapshot to load_config unconditionally.

BACKLOG #2596
Fixes from the second code-review round:

- read_trusted_snapshot read a directory gone after its check as an
  empty one when lax, so a load ran an empty graph, or with allow_empty
  returned one, where it must answer as missing (BACKLOG #3157).
- dec_snapshot caught only binascii.Error; a non-ASCII str segment
  raised a bare ValueError past the codec's own refusal type.
- The reload's strict snapshot warning now says what follows a failed
  read, keeping its greppable prefix.
- Docstrings no longer say a worker re-reads codesets/ or that a
  content-only refusal is a disk edit.

BACKLOG #2596
@wshallwshall wshallwshall added the qa Builder QA record posted; not a merge gate label Oct 27, 2026
@wshallwshall
wshallwshall marked this pull request as ready for review October 27, 2026 07:23
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander: ready for you. Code review ran at xhigh, 2 rounds (QA line in the body), covering head e33f5fb. Nine open findings are listed for filing; items 1 and 2 change behaviour (item 2 is an audit-label change with a Manager reading). Read the Linux, Postgres and SQL Server legs.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander: do not merge yet. semgrep (project SAST rules) is red on e33f5fb: mf-no-eval-exec fires on the new compile+exec in messagefoundry/config/wiring.py. A Builder is replacing it with an in-memory importlib loader (no exemption) and will re-review the delta; I will post the new head and its QA line here.

greptile-apps[bot]
greptile-apps Bot previously approved these changes Oct 27, 2026
…pshot

The snapshot loader compiled each config module and helper with compile()
and ran it with a literal exec(). It now gives each module a spec whose
loader is _SnapshotLoader, an importlib.abc.InspectLoader that serves the
snapshot's bytes for that file, and runs it with spec.loader.exec_module,
the import system's own path, as origin/main did with
spec_from_file_location. Config modules are code-first by design and still
execute through importlib; only where the bytes come from changed.

Same semantics: modules, order, names, sys.modules handling, helper
imports and error classes are unchanged. The code object is compiled from
the snapshot bytes under the file's own path, the loader has no bytecode
methods so no __pycache__ file is read or written, and importlib.reload
still finds no spec for the mangled name. get_source now serves the same
bytes. No get_data: pkgutil.get_data stays unavailable (open finding 3).

Clears semgrep mf-no-eval-exec with no exemption. Test: the helper probe
also checks both modules' loader, origin, __file__, __cached__ and source;
a new test pins that reload refuses a config module.

BACKLOG #2596
Review round 1 on the loader change. get_code calls compile() itself
rather than InspectLoader.source_to_code, so a parser wall (MemoryError,
RecursionError) is still judged as raised in this package and keeps its
wording, as before the loader. get_source returns None: a served source
let linecache cache a missing file's lines with no time to check, so a
later load of the same path showed the old lines. The spec comment now
says what it builds, and the reload test checks the module was not
re-run and restores the spec reload cleared.

BACKLOG #2596
Review round 2 on the loader change. The first get_code drops the
snapshot, so runpy.run_module gets no code and refuses a loaded config
module, as it did when the module had no loader, and a module kept alive
after a reload holds no snapshot bytes (a linecache lazy entry holds only
the path). The byte lookup is back inside the run, in _exec_module's try,
so a missing file stays a WiringError. Tests: runpy refuses, and the
compile-wall test checks the cause is MemoryError or RecursionError.

BACKLOG #2596
@greptile-apps
greptile-apps Bot dismissed their stale review October 27, 2026 08:23

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander: hold lifted. New head 02c1bc9 replaces the literal exec with an importlib InspectLoader (no exemption); semgrep shows 0 findings locally with CI's command. The delta e33f5fb..02c1bc9 was code-reviewed at xhigh, 2 rounds; QA line in the body under Round 3. Read semgrep, repo-scan, the pytest legs (Linux, Postgres, SQL Server) and CI gate on this head.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander (session 8550b02b), 2026-10-27 ~13:55Z. Held at 02c1bc98b5 for an owner ruling on body items 1, 2 and 3, which all change behaviour: 1 is fail-closed on an unreadable unused helper; 2 moves the tamper audit from digest_mismatch to invalid config, and the row loses its digest fields; 3 means config code gets no get_data/importlib.resources. Whatever the ruling, one fix is owed now: docs/SECURITY.md (~2330-2345) still says "After a load that succeeds, it checks again ... So an edit that also breaks the graph is recorded as an edit", which describes the re-check this PR deletes (SDS-3.7). Please correct it in this branch.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander: please hold PR 2556; do not merge 02c1bc9.

The batch 437 Manager ruled on the two behaviour-changing findings after an independent adversarial review. Both are KEPT:

  • Item 1: an unreadable _ helper now refuses the load. This is a fail-closed tightening. The trust check already listed every *.py, and dot-named editor and lock files are excluded.
  • Item 2: on an approved release the trust check runs before the digest. The refusal is unchanged. The new order reads no untrusted bytes before the trust check, which the old order did. No consumer in the repo keys on digest_mismatch beyond the API and docs.

The review found one real gap: docs/SECURITY.md still describes post-load digest re-checks that no longer exist (SDS-3.7). A Builder is now:

  • fixing that text and a stale engine WARNING;
  • adding approval context (the approved fingerprint and a marker that the digest check was not reached) to the invalid_config audit row on a released reload, with a test;
  • running an xhigh code-review round over the delta.
    I will post the new head and its QA line here.

Engine.reload_detail raises ConfigRefusedBeforeApprovalCheck, a
WiringError keeping the refusal's message, cause and traceback, when a
released reload is refused by the settings preflight or the source
trust check before the snapshot's digest is checked. The
config_reload_failed row then carries approved_fingerprint and
approval_digest_check "not_reached", and keeps its reason. A load that
fails after the check passed carries neither. The snapshot read's
WARNING now says any other reload refuses every unreadable file the
load reads.

BACKLOG #2596
SECURITY.md no longer says a released reload checks the digest again
after the load, or once more after an invalid-config failure. It says
the trust check runs first and reads no contents, the digest is checked
against the snapshot that loads, a tamper that also loosens permissions
is audited invalid_config with the not-reached marker, and an
unreadable helper refuses a start or reload. SERVICE.md and
CONFIGURATION.md drop the claim that a __pycache__ file runs. Adds the
changelog fragment.

BACKLOG #2596
…re than the code does

The docs and changelog say the marker comes with a WiringError refusal
before the digest check, that a missing or disallowed directory gets
neither field, that a refusal after the check can come from inputs the
digest does not cover, and that the environment value file is read
again after the check. The __pycache__ claim names config modules and
helpers only. The engine WARNING names the environments folder, and a
test assertion that could not fail is gone.

BACKLOG #2596
… from disk

The docs and changelog say the loader compiles config modules and the
helpers an import statement reaches, and that a module reached through
the ordinary import system can still run a cache file. The start check
docstring drops the clause CONFIGURATION.md dropped. The limits list
says a folder removed between the two listings is left out, and the
trust-check test also asserts nothing ran, nothing swapped and the
approval failed.

BACKLOG #2596

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-red A required check went red. Attribute it before retrying. qa Builder QA record posted; not a merge gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant