Skip to content

fix(security): encrypt credential-bearing system_settings rows at rest (ent#435, CWE-312) - #2330

Merged
vybe merged 5 commits into
devfrom
fix/ent435-encrypt-settings-credentials
Aug 20, 2026
Merged

vybe merged 5 commits into
devfrom
fix/ent435-encrypt-settings-credentials

Conversation

@dolho

@dolho dolho commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the CWE-312 finding in Abilityai/trinity-enterprise#435. Filed on the private tracker per disclosure policy; the write paths are OSS and backend-agnostic, so the fix lands here. OSS-core by decision — no requires_entitlement, logic stays in the OSS tree (recorded explicitly, since CLAUDE.md's default for an enterprise-tracker issue is gated unless ruled otherwise).

The defect

Six system_settings rows held live third-party credentials in cleartext: anthropic_api_key, github_pat, google_api_key, slack_app_token, slack_client_secret, slack_signing_secret.

Meanwhile Architectural Invariant #12's own table read as though everything was covered. So every DB dump, backup, replica and snapshot carried usable tokens — backups being exactly the artifact most likely to travel — and any read path to the database yielded working credentials without needing CREDENTIAL_ENCRYPTION_KEY.

platform-settings.md was worse than silent about it. Security consideration #5 advertised the defect as a control:

"Secure Storage: Keys stored in SQLite, not Redis (persistent, encrypted at rest if filesystem supports it)"

Filesystem encryption protects a powered-off disk and nothing that moves. That bullet is rewritten to say what was actually true and why.

Design decisions worth reviewing

The key NAME moves, not just the value. Each secret becomes an AES-256-GCM envelope under <key>_encrypted and the cleartext row is deleted. Leaving an encrypted value under the original name would keep "is this install encrypted?" unanswerable by inspection — which is the reported defect, not a cosmetic detail. With the rename, the reporter's own verification query returning nothing is the proof, and the sink guard keeps it true.

The read path lazily migrates. A one-shot migration converts disk once, but a restored pre-fix backup, a rollback-then-roll-forward, or a direct DB write can put cleartext back — and only the reader would notice. _resolve_secret_setting goes encrypted → legacy-cleartext (encrypted-and-deleted on sight) → env → '', making cleartext transient by construction rather than merely absent right now. Steady state is unchanged: while the encrypted row exists the legacy key is never read (pinned by a byte-equality test — a rewrite would change the nonce).

Fail directions are deliberately opposite. Read fails open to env (an unreadable envelope must not 500 the agent-start path) but never down to a stale legacy row, which would resurrect a credential the operator had replaced. Write fails closed: no encryption key means refuse, never silently store cleartext.

The guard is at the SINK, not only on the routes. db.set_setting raises SecretSettingWriteError (mapped to 422) for a registered secret key or any merely credential-shaped key (*_api_key/*_token/*_secret/*_pat/*_password/*_credentials). Two reasons it cannot live on the routes alone: system_settings has four writers, and the generic PUT /api/settings/{key} catch-all can address any key — the same door #506, #1609, ent#12, #1644, ent#14 and ent#346 each found standing open. The explicit set gives a precise refusal with a pointer; the shape heuristic catches the next secret nobody registers.

slack_client_id is a reasoned exemption, not an omission. An OAuth client_id is a public identifier that slack_service.get_oauth_url puts verbatim into the browser-visible authorize URL — the whatsapp_bindings.account_sid "(public)" precedent. Recorded with its reason in PUBLIC_CREDENTIAL_SHAPED_KEYS and pinned by a test, so a later reader can tell reviewed from overlooked. Happy to encrypt it anyway if you disagree.

Dual-track per Invariant #9: secret_settings_encryption (SQLite) + Alembic 0041 (PostgreSQL — the backend the defect was reported on, so not the afterthought). Both call one plan_migration: the two drivers cannot share SQL, but they must not disagree on policy. Hard-fails on a missing encryption key only when there is something to encrypt, so a fresh install still boots. downgrade() is a deliberate no-op — the honest inverse is "write these live credentials back in cleartext".

Found in passing, fixed with it

scripts/deploy/rotate-credential-key.py sweeps columns, so elevenlabs_api_key_encrypted (ent#117) and a2a_outbound_endpoints_encrypted (#736) — envelope-in-a-row — were invisible to every key rotation. A "completed" rotation left them readable only via the secondary key, and dead the moment it was removed. Pre-existing, unrelated to the six rows, same shape. Now swept, with membership derived from the policy module so a newly-registered credential setting joins rotation automatically.

Operator impact — rotation is the remediation

Encryption protects the database going forward only. Historical backups still hold the plaintext, and row deletion does not guarantee byte-scrubbing (SQLite freed pages, PG dead tuples). The runbook's headline instruction is therefore rotate: docs/migrations/SECRET_SETTINGS_ENCRYPTION_2026-08.md.

Verification

Beyond unit tests, exercised as a real system:

  • Against a restored copy of a real dev database (7 agents, 1 user, 13 settings), booted dev-style with src/backend bind-mounted — not the image: migrated 0038 → 0041 across three revisions, reporter's query returns nothing, zero plaintext left, all 13 original settings and all 7 agent rows intact.
  • Real container boot (built image, real PG): migration runs at startup with the ROTATE warning, runs exactly once across two boots, zero decrypt failures.
  • PostgreSQL: real upgrade_to_head, idempotent re-run, downgrade + re-upgrade safe.
  • Fail-closed: no CREDENTIAL_ENCRYPTION_KEY with credentials present → container exits 1 with an actionable message, cleartext preserved, nothing destroyed; supplying the key boots and migrates.
  • Key rotation: all four secrets survive a full rotation with the old key destroyed — with a negative control confirming the pre-fix script loses the ent#117/feat: add call_a2a_agent MCP tool for outbound A2A protocol calls #736 ones.
  • The issue's central claim: pg_dump and a SQLite backup artifact both contain zero plaintext.
  • Packaging (bug: backend image missing redis_breaker_util.py — Dockerfile COPY not added for new top-level module (#986) #1033 class): new module and revision confirmed present inside the built image.
  • Live HTTP API: masked status, 422 on five refusal cases, ordinary config still 200, write/read/delete round trip, GET /api/settings leaks nothing.
  • Edge cases: unicode / 100 KB / newline / quote-laden values, four malformed envelope shapes, precedence, 8-thread concurrency on both read and write, all four sinks.
  • Consumers: Slack OAuth URL + request-signature verification, the feat: Per-agent GitHub PAT configuration #347/ent#162 PAT ladder, agent env assembly, compatibility AI checks, system agent, enterprise cross-model validation.

Tests: 39 new (36 OSS + 3 enterprise twin). Targeted regressions 278 passed. Full unit suite: 11930 passed, 20 failed — all 20 verified identical on clean origin/dev (15 are a Python 3.12-vs-3.13 str() difference for IPv4-mapped IPv6 addresses; my local venv is 3.12, CI is 3.13. 4 are a pre-existing test-stub ImportError. 1 is test_1920 flagging enterprise/backend/siem/exporter.py, present only because I checked the submodule out — public CI does not).

Anti-recurrence: tests/unit/test_ent435_settings_sink_guard.py (AST — every system_settings writer is gated or listed with the reason it cannot carry a credential).

Companion PR

The two enterprise system_settings sinks carry the same guard plus a private twin of the AST test, per the #1677 caller-parity convention: Abilityai/trinity-enterprise#436. Both are inert for their fixed non-credential keys; they exist so the next key added there cannot be a secret.

Merge order: this PR FIRST, then ent#436, then the submodule pointer bump. (Corrected — I originally wrote it the other way round.) The enterprise guard imports services.secret_settings, which this PR introduces, and trinity-enterprise CI checks out OSS dev — so ent#436 is red with ModuleNotFoundError: No module named 'services.secret_settings' until this lands, and goes green on a re-run afterwards. That import is deliberately not wrapped in a try/except ImportError: silently degrading would disable the guard on exactly the tree where it matters. This PR does not bump the submodule pointer.

Related to Abilityai/trinity-enterprise#435

🤖 Generated with Claude Code

dolho added 2 commits August 20, 2026 12:54
…t (ent#435, CWE-312)

Six `system_settings` rows held LIVE third-party credentials in cleartext —
`anthropic_api_key`, `github_pat`, `google_api_key`, `slack_app_token`,
`slack_client_secret`, `slack_signing_secret` — while Architectural Invariant
#12's own table read as though everything was covered. Every DB dump, backup,
replica and snapshot therefore carried usable tokens (backups being exactly the
artifact most likely to travel), and any read path to the database yielded
working credentials WITHOUT needing CREDENTIAL_ENCRYPTION_KEY.

`platform-settings.md` was worse than silent about it: security consideration #5
advertised the defect as a control ("stored in SQLite ... encrypted at rest if
filesystem supports it"), which protects a powered-off disk and nothing that
moves. That bullet is rewritten to say what was actually true and why.

The key NAME moves, not just the value. Each secret becomes an AES-256-GCM
envelope under `<key>_encrypted` and the cleartext row is DELETED. Leaving an
encrypted value under the original name would keep "is this install encrypted?"
unanswerable by inspection — which IS the reported defect, not a cosmetic
detail. With the rename, the reporter's own verification query returning nothing
is the proof, and the sink guard keeps it true.

The read path lazily migrates. A one-shot migration converts disk once, but a
restored pre-fix backup, a rollback-then-roll-forward, or a direct DB write can
put cleartext back and only the reader would notice. `_resolve_secret_setting`
resolves encrypted -> legacy-encrypted-and-deleted-on-sight -> env -> "", so
cleartext is transient by construction rather than merely absent right now.
Steady state is unchanged: while the encrypted row exists the legacy key is
never read (pinned by a byte-equality test — a rewrite would change the nonce).

Fail directions are deliberately opposite. Read fails OPEN to env (an unreadable
envelope must not 500 the agent-start path) but never down to a stale legacy
row, which would resurrect a credential the operator had replaced. Write fails
CLOSED: no encryption key means refuse, never silently store cleartext.

The guard is at the SINK, not only on the routes. `db.set_setting` raises
`SecretSettingWriteError` (mapped to 422) for a registered secret key or any
merely credential-shaped key (`*_api_key`/`*_token`/`*_secret`/`*_pat`/
`*_password`/`*_credentials`). Two reasons it cannot live on the routes alone:
`system_settings` has four writers, and the generic `PUT /api/settings/{key}`
catch-all can address ANY key — the same door #506, #1609, ent#12, #1644, ent#14
and ent#346 each found standing open. The explicit set gives a precise refusal
with a pointer; the shape heuristic catches the NEXT secret nobody registers.
Writing a `*_encrypted` key raw is refused too, since a hand-pasted string would
land as a row every reader then fails to decrypt (the #736 rationale).

`slack_client_id` is a reasoned exemption, not an omission: an OAuth client_id
is a public identifier that `slack_service.get_oauth_url` puts verbatim into the
browser-visible authorize URL (the `whatsapp_bindings.account_sid` "(public)"
precedent). Recorded with its reason in `PUBLIC_CREDENTIAL_SHAPED_KEYS` so a
later reader can tell reviewed from overlooked, and pinned by a test.

Dual-track per Invariant #9: `secret_settings_encryption` (SQLite) and Alembic
`0041_secret_settings_encryption` (PostgreSQL — the backend the defect was
reported on, so not the afterthought). Both call ONE `plan_migration`: the two
drivers cannot share SQL, but they must not disagree on policy. Hard-fails on a
missing encryption key only when there is something to encrypt, so a fresh
install still boots (the #453 choice, refined). `downgrade()` is a deliberate
no-op — the honest inverse is "write these live credentials back in cleartext".

Found in passing and fixed with it: `rotate-credential-key.py` sweeps COLUMNS,
so `elevenlabs_api_key_encrypted` (ent#117) and `a2a_outbound_endpoints_encrypted`
(#736) — envelope-in-a-ROW — were invisible to every key rotation, leaving them
readable only via the secondary key and dead the moment it was removed. It gains
a row-keyed `system_settings` pass whose membership derives from the policy
module, so a newly-registered credential setting joins rotation automatically.

Encryption protects the database going forward only. Historical backups still
hold the plaintext, so the runbook's headline instruction is ROTATE:
docs/migrations/SECRET_SETTINGS_ENCRYPTION_2026-08.md.

Guards: tests/unit/test_ent435_settings_sink_guard.py (AST — every
`system_settings` writer is gated or listed with the reason it cannot carry a
credential; OSS tree only, the private submodule owns its twin per #1677).

Related to Abilityai/trinity-enterprise#435
… empty envelope (ent#435)

Found while testing the ent#435 encryption change: `set_secret_setting(key, "")`
stored an AES-256-GCM envelope OF an empty string. Resolution was unaffected —
the decrypted `""` is falsy, so every reader fell through to the env var exactly
as before — but the row's mere existence made `has_secret_setting` true, so the
`source: settings|env` field on the admin status endpoints reported "settings"
for a credential that actually resolves from the environment.

Pre-ent#435 a blank write stored `''`, which readers treated as falsy and fell
through the same way, and `bool(db.get_setting_value(...))` on `''` was False —
so "settings" was never reported. Clearing instead of storing preserves the
original resolution AND the original source reporting, which makes this a
divergence introduced by ent#435 rather than a pre-existing behaviour.

Related to Abilityai/trinity-enterprise#435
@dolho dolho changed the title fix(security): encrypt credential-bearing system_settings rows at rest (ent#435, CWE-312) wipfix(security): encrypt credential-bearing system_settings rows at rest (ent#435, CWE-312) Aug 20, 2026
@dolho dolho changed the title wipfix(security): encrypt credential-bearing system_settings rows at rest (ent#435, CWE-312) wip: fix(security): encrypt credential-bearing system_settings rows at rest (ent#435, CWE-312) Aug 20, 2026
dolho added 2 commits August 20, 2026 13:11
…or (ent#435)

The ent#435 migration refuses to boot when `CREDENTIAL_ENCRYPTION_KEY` is unset
AND there are cleartext credentials to encrypt. That refusal is correct — the
alternative is booting with the credentials still readable in every dump — but
it surfaced as the generic `CredentialEncryptionService` message, which says
what to set and nothing about why it suddenly matters.

Almost no install can hit this: `start.sh` has auto-generated the key via
`ensure_hex32_secret` since v0.6.0 (2026-06-01), three weeks BEFORE it started
generating `AGENT_AUTH_SECRET`, so any install with a working agent fleet
provably has one. The gap is an install provisioned by a bare `docker compose
up` — compose defaults the variable to empty and `.env.example` ships it blank
— that ALSO configured credentials through the UI. For that operator the
platform has always worked, so a bare "encryption key not configured" gives no
clue that an upgrade is what changed.

`MissingEncryptionKeyError` now names ent#435, states why refusing to start is
deliberate, gives the one-line fix, and points at the runbook. It subclasses
ValueError, so the migration runner's failure path and any `except ValueError`
caller are unchanged; the failing credential's VALUE never appears in the
message (asserted). Verified at container level: exit 1, full message in the
log, cleartext row preserved.

Runbook gains the same provenance so the rare case is diagnosable from the docs
as well as the log.

Related to Abilityai/trinity-enterprise#435
…ings.py load

CI's regression-diff caught this: the 4 `test_telegram_webhook_backfill` tests
passed on base and failed on head.

That test loads `routers/settings.py` as a standalone module against a
hand-built `services.settings_service` stub whose attributes are enumerated
explicitly — the stub is extended per-issue by convention (#1310, #1129, #506,
#1609, ent#236). ent#435 added `set_secret_setting` / `clear_secret_setting` /
`has_secret_setting` to that router's module-level import, so the load raised
`ImportError: cannot import name 'set_secret_setting'`.

Proven both directions under CI's ordering (a prior test must seed
`services.telemetry_sharing_service` into sys.modules for this file to load at
all — a pre-existing order-dependency, unrelated): without the stub entries
4 fail with that ImportError, with them 14 pass.

Worth recording why this was not caught locally: in a Python 3.12 venv these 4
fail on BOTH base and head for an unrelated reason (`cannot import name
'telemetry_sharing_service'`), so a local base-vs-head comparison showed
"identical, pre-existing" and hid a genuine head-only regression. The 3.13
image that matches CI is the environment that distinguishes them.

Related to Abilityai/trinity-enterprise#435
@dolho

dolho commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Review

Reviewed the diff and the CI signal. Green and ready — with one genuine regression found and fixed during review, plus a correction to something I claimed earlier.

CI caught a real regression I had misdiagnosed

regression diff failed on the first push: 4 test_telegram_webhook_backfill tests passed on base and failed on head.

I had previously reported these as "pre-existing, identical on clean origin/dev". That was wrong, and the way it was wrong is worth recording. In a local Python 3.12 venv those 4 fail on both sides for an unrelated reason (cannot import name 'telemetry_sharing_service'), so a local base-vs-head comparison showed "identical" and hid a genuine head-only failure. Only the 3.13 environment CI uses distinguishes them.

Root cause: that test loads routers/settings.py standalone against a hand-built services.settings_service stub whose attributes are enumerated explicitly (extended per-issue by convention — #1310, #1129, #506, #1609, ent#236). This PR added three names to that router's module-level import, so the load raised ImportError: cannot import name 'set_secret_setting'.

Fixed in c1dca2d4, proven both directions under CI's ordering: without the stub entries 4 fail with that ImportError, with them 14 pass. regression diff is now green.

CI status

21 success / 4 skipped / 1 cancelled. All three head pytest shards pass; regression diff, schema-parity, pg-migrations, gitleaks, prod-image-smoke, backend boots without enterprise submodule, CodeQL, verify-non-root all pass. The single cancelled run is a base shard (runner timeout) — the diff proceeded on surviving seeds and reported no new failures.

Review findings

No stragglers. Grepped both trees for any remaining direct read or write of the six legacy keys — get_setting_value('anthropic_api_key') and friends would now silently return None. Zero hits in src/backend, zero in the enterprise submodule, zero in the MCP server / scheduler / agent-server. Every consumer goes through the resolver.

Frontend is untouched and slightly better off. No files changed under src/frontend/. stores/settings.js does fetch bare /api/settings, but Settings.vue reads only trinityPrompt from it — so no list rendering of envelopes. Worth noting the direction of travel: before this PR that fetch pulled the cleartext credentials into every admin's browser; now it pulls envelopes.

gitleaks passing is meaningful here. Everything in the test fixtures is fabricated and non-functional (sk-ant-api03-abcdef, ghp_abcdefghijklmnop, AIzaSyAbcdef) — deliberately real-shaped so the test can assert looks_like_envelope doesn't misclassify a genuine token as already-encrypted.

Known residual, flagged not hidden

With the encryption key missing, the admin status endpoint reports source: "settings" while the mask shows the env value — because has_secret_setting is presence-only by design. It's confusing in that one broken state. Verified the state is otherwise safe: the instance boots, every endpoint returns 200, it degrades to the env var, the encrypted row is preserved, and restoring the key brings the credential back exactly. Fixing it properly means threading provenance out of _resolve_secret_setting, which changes the API response shape — happy to do it here or as a follow-up, reviewer's call.

Also unchanged and deliberate: slack_client_id stays cleartext as a public OAuth identifier. Say the word if you'd rather encrypt it anyway.

@dolho dolho changed the title wip: fix(security): encrypt credential-bearing system_settings rows at rest (ent#435, CWE-312) fix(security): encrypt credential-bearing system_settings rows at rest (ent#435, CWE-312) Aug 20, 2026
@dolho

dolho commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Re-review (post-fix)

Green on this repo, and the re-read turned up one more thing I had stated wrongly — see the merge-order note at the end.

CI on c1dca2d4

21 success / 4 skipped / 1 cancelled. regression diff — the gate that caught the stub breakage — is now success, with all three HEAD pytest shards passing. The single cancelled run is a base shard (runner timeout); the diff proceeded on surviving seeds and reported no new failures. schema-parity, pg-migrations, gitleaks, prod-image-smoke, backend boots without enterprise submodule, CodeQL and verify-non-root all pass.

The shape heuristic — the risk I most wanted to disprove

The explicit six-key set is safe by construction. The heuristic half (*_api_key/*_token/*_secret/*_pat/*_password/*_credentials) is the part that could break an unrelated existing writer at runtime, so I tested it rather than reasoned about it: extracted 285 candidate setting keys from the whole backend — every literal passed to a settings writer, every *_KEY constant, and the OPS_SETTINGS_DEFAULTS / AGENT_QUOTA_DEFAULTS / PROACTIVE_RATE_LIMIT_DEFAULTS dict keys — and ran each through is_credential_shaped.

Six names came back flagged: client_secret, oidc_client_secret, siem_token, signing_secret, totp_secret, webhook_secret. None of them is a system_settings key — zero set_setting call sites between them. They are request-body fields and field names inside AES-GCM envelopes (sso/service.py:_SECRET_KEY, siem/service.py:_TOKEN_KEY, two_factor/service.py:_TOKEN_KEY). Fittingly: they are already-encrypted payload names, which is exactly the pattern this PR makes the six settings adopt.

Collateral: none.

Structural re-read

  • Guard present at all three OSS writers (db/settings.py, client_portal/db.py, and a pre-check in routers/settings.py), imported lazily at each — matching every existing db/ → services/credential_encryption edge.
  • services/secret_settings.py is a true leaf: stdlib-only at module level (json, logging, typing), so the db/ → policy edge cannot invert Invariant Fix: Add missing Docker labels to system agent container #1's layering.
  • The one broad except Exception I added is the documented read-path guard in _migrate_legacy_secret_setting; it runs after a committed read and cannot affect the returned credential.
  • Rebased on current dev, single Alembic head (0041), no frontend files touched.

On whether CREDENTIAL_ENCRYPTION_KEY is universally present

Worth stating precisely, since the fail-closed migration depends on it. .env.example labels it "REQUIRED for production", start.sh has auto-generated it via ensure_hex32_secret since v0.6.0 (2026-06-01) — three weeks before it began generating AGENT_AUTH_SECRET — and 23 modules already consume it (subscriptions, Telegram/WhatsApp/Slack bindings, per-agent and per-user PATs, webhook signing, VoIP, ElevenLabs, outbound A2A, and every enterprise module: SSO, 2FA, SIEM).

The one nuance, stated because it is the honest limit of the claim: the agent-start path deliberately tolerates its absence (lifecycle.py → {"status": "skipped", "reason": "encryption_not_configured"}), and there is no boot-time check. So a minimal install that touches none of the above can technically run without it today, and for that operator this upgrade is a genuine posture change. That is precisely why 53599f1c makes the refusal name ent#435, explain why refusing is deliberate, give the one-line fix, and point at the runbook — verified at container level: exit 1, full message in the log, cleartext row preserved, and the failing credential's value never appears in the message.

Correction: merge order was backwards

I wrote "enterprise first, then the pointer bump" in both PR bodies. That is wrong and I have corrected both. The enterprise guard imports services.secret_settings, which this PR introduces, and trinity-enterprise CI checks out OSS dev — so ent#436 is currently red with ModuleNotFoundError: No module named 'services.secret_settings' and can only go green after this lands.

Correct order: this PR → ent#436 → submodule pointer bump.

The import there is deliberately not wrapped in try/except ImportError: degrading silently would disable the credential guard on exactly the tree where it is meant to apply.

Still open for the reviewer

  1. With the encryption key missing, status reports source: "settings" while the mask shows the env value (has_secret_setting is presence-only). Safe otherwise — boots, all endpoints 200, degrades to env, encrypted row preserved, restores exactly when the key returns. Fixing it changes the API response shape, so: here or follow-up?
  2. slack_client_id stays cleartext as a public OAuth identifier.

…per row

Found on re-review: both migration docstrings claimed "write-then-delete per
row, so a crash mid-sweep leaves cleartext intact". The safety conclusion was
right but the mechanism described was not — the SQLite sweep issues a single
`conn.commit()` after the loop, and the Alembic revision runs inside Alembic's
own migration transaction. Both are all-or-nothing across every row.

That distinction matters more than a wording nit: single-transaction is the
STRONGER property (a crash rolls back rather than leaving three of six
credentials converted, and the runner never records the migration as applied),
so a future reader trusting the old docstring could "fix" the loop into a
per-row commit and quietly trade atomicity for a partially-converted state
whose only recovery is the read-path lazy migration.

Docstrings now describe what the code does and say explicitly not to make that
change. `test_sqlite_sweep_is_atomic_across_rows` pins it: failing the DELETE of
the second row leaves all three cleartext rows intact and zero encrypted rows,
and a clean re-run converges completely — so the property is asserted rather
than asserted-in-prose.

The per-row, two-transaction description on the SERVICE path
(`_migrate_legacy_secret_setting`) is accurate and unchanged — that one really
is two separate db calls, deliberately, because it runs on a read path.

Related to Abilityai/trinity-enterprise#435
@dolho

dolho commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Re-review #3

The merge-order fix was documentation only, so the code was unchanged — which meant this pass had to look somewhere it hadn't. It did, and it found one more real thing.

Finding: the migration docstrings misdescribed their own transaction semantics

Both migrations claimed "write-then-delete per row, so a crash mid-sweep leaves cleartext intact". The conclusion was right; the mechanism was not. The SQLite sweep issues a single conn.commit() after the loop, and the Alembic revision runs inside Alembic's own migration transaction. Both are all-or-nothing across every row.

That is not a wording nit, because single-transaction is the stronger property: a crash rolls back rather than leaving an install with three of six credentials converted, and the runner never records the migration as applied. A future reader trusting the old docstring could "fix" the loop into a per-row commit and quietly trade atomicity for a partially-converted state whose only recovery is the read-path lazy migration.

Fixed in 496005e3. The docstrings now describe what the code does and say explicitly not to make that change — and the property is pinned by a test rather than asserted in prose: test_sqlite_sweep_is_atomic_across_rows fails the DELETE of the second row and asserts all three cleartext rows survive with zero encrypted rows, then that a clean re-run converges completely.

The per-row, two-transaction description on the service path (_migrate_legacy_secret_setting) is accurate and deliberately unchanged — that one really is two separate db calls, because it runs on a read path where a failure must still return the credential.

CI on 496005e3

22 success / 4 skipped / 0 failures / 0 cancelled — the cleanest run on this branch. regression diff success; all three HEAD and all three BASE pytest shards green (the earlier runs each lost one shard to a runner timeout — this one lost none). schema-parity, pg-migrations, gitleaks, prod-image-smoke, backend boots without enterprise submodule, CodeQL, verify-non-root all pass.

Merge order — verified corrected in both bodies

Carried forward from re-review #2 (still holds)

  • Shape-heuristic collateral: none — 285 candidate setting keys scanned; the six flagged names are request-body fields and AES-GCM envelope field names, with zero set_setting call sites between them.
  • No stragglers — no remaining direct read/write of the six legacy keys anywhere, including the enterprise submodule, MCP server, scheduler and agent-server.
  • Frontend untouched, and stores/settings.js now pulls envelopes where it used to pull cleartext credentials into every admin's browser.

Two decisions still open for the reviewer

  1. With the encryption key missing, status reports source: "settings" while masking the env value (has_secret_setting is presence-only). Safe otherwise — boots, all endpoints 200, degrades to env, encrypted row preserved, restores exactly when the key returns. Fixing it changes the API response shape: here or follow-up?
  2. slack_client_id stays cleartext as a public OAuth identifier.

Nothing else outstanding from my side.

@dolho
dolho requested a review from vybe August 20, 2026 11:46
dolho added a commit that referenced this pull request Aug 20, 2026
Two conflicts, both from dev moving ahead by 12 commits:

1. db/migrations.py MIGRATIONS list — both sides appended. Kept BOTH, with
   dev's two entries first and this branch's last, so the runner applies them
   in the order they landed.

2. Alembic multi-head — dev added 0039_operator_queue_addressed_to and
   0040_rl_events_failure_kind while this branch carried its own 0039 off the
   same 0038 parent. Two heads make `alembic upgrade head` apply ZERO revisions
   (Invariant #3), so every revision since the fork silently stops arriving.
   Renamed 0039_agent_ownership_operator_resume -> 0041_… and re-parented it
   onto 0040_rl_events_failure_kind. check_alembic_heads now reports one head.

NOTE: PR #2330 also claims 0041 (0041_secret_settings_encryption, same 0040
parent). Whichever of the two merges second must re-parent onto the other —
scripts/ci/check_alembic_heads.py is a required, unconditional gate, so it
fails loudly rather than shipping a silently-inert migration chain.
@dolho

dolho commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Heads-up: Alembic 0041 now collides with #2312

While unblocking #2312 I had to re-parent its Alembic revision onto dev's current head, which lands it on 0041_agent_ownership_operator_resume (down_revision = 0040_rl_events_failure_kind) — the same slot and parent this PR's 0041_secret_settings_encryption uses.

There is no ordering-free fix: a down_revision can only reference a revision that already exists, so whichever of the two merges second must re-parent onto the other — rename to 0042_… and set down_revision to the first one's id. One line, plus the filename.

Worth stating why this is a flag and not a problem: it fails loudly. scripts/ci/check_alembic_heads.py is wired unconditionally into schema-parity, so the second PR goes red on the multi-head rather than merging a chain where alembic upgrade head silently applies zero revisions (Invariant #3). I am flagging it only so whoever hits the red knows immediately what it is.

No change to this PR — still 22 success / 4 skipped / 0 failures on 496005e3.

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validated via /validate-pr: security scans clean, dual-track migration present (SQLite secret_settings_encryption + Alembic 0041), docs updated (architecture.md, feature-flows/platform-settings.md, requirements/credentials.md, migration runbook), regression tests present, all CI green incl. pg-migrations and schema-parity.

Merging FIRST of the three PRs that each claim 0041 off 0040_rl_events_failure_kind — #2312 and #2331 will be re-parented onto 0041_secret_settings_encryption before they merge. Cross-tracker ent#435 needs a manual status-in-dev bump.

@vybe
vybe merged commit 3b545e4 into dev Aug 20, 2026
26 checks passed
vybe pushed a commit that referenced this pull request Sep 20, 2026
…in, and a tier it does not reach fails the run (#2888) (#2893)

Fixes #2888

The unclassified-directory guard globbed `tests/*/` after the script had
already `cd`'d into `tests/`, so it looked for `tests/tests/*/`, matched
nothing, stayed a literal `*` under default globbing, failed
classification and `exit 1`'d the run at the api boundary. Every
full-suite run since a646de8 (2026-08-27) stopped before the `api`,
`standalone` and `postgres` tiers — ~1,900 tests — and nothing said so.

- The guard is now `tests/harness/check_test_dirs.py`, handed TESTS_DIR
  explicitly instead of globbing relative to the cwd, and testable: only
  a directory that HOLDS test files is a finding (`__pycache__`,
  `reports/`, a stale local checkout with only `node_modules` are on
  every machine and collected by nothing), and an owner entry with no
  directory on disk is flagged too (pytest ignores a missing `--ignore=`
  path silently).
- Tier ledger: DECLARED_TIERS is checked against the recorded rows at
  the end. A declared tier with no row that the caller did not deselect
  is reported `NEVER RAN` and fails the run; deselected tiers (`--tier`,
  `--no-pg`) are listed as SKIP and the verdict says `partial`.
- Abort trap: any exit before the summary — including SIGINT/SIGTERM —
  prints an ABORTED banner naming the tiers left unrun and exits
  non-zero. The disposable postgres container is torn down on that path
  too (the old per-tier `trap ... EXIT` is folded into it).
- The alembic step of the postgres tier now carries the same dummy
  Redis/encryption env pg-migrations.yml sets: revision 0041 (#2330)
  imports `services` → `config`, which raises without Redis credentials.
  Broken since 2026-08-20, a week before the tier stopped being reached.

Guards in tests/unit/test_2080_harness_contract.py: the glob is matched
as code (not the comment that names it), the guard passes on the real
tree with the real owner lists, fires on an unwired directory holding
tests, ignores test-less directories, every `run_tier` name is in
DECLARED_TIERS, and the NEVER-RAN / abort paths are present.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants