fix(settings): one validated, audited write path for retention windows (ent#297) - #1893
Conversation
…s (ent#297) ent#297 is the root-cause issue for a class point-fixed five times: an agent-scoped MCP key resolves to its owner CARRYING THE OWNER'S ROLE, so on a default admin-owned install every agent's injected TRINITY_MCP_API_KEY passed every admin gate. The root fix — reject_agent_principal inside require_admin and assert_admin — is #1890 (AC 1/2/3/6). This is the other half: AC #4 and #5, the retention windows the attack actually reached. Those windows had TWO write paths and neither validated anything: PUT /api/settings/{key} -> bare db.set_setting, no type/range check at all. #1644 blocked the guard's ACK keys here but left the WINDOWS falling through. PUT /api/settings/ops/config -> Dict[str, str] written straight through, and not audit-logged, unlike the generic PUT right above it. Now: the catch-all 422s all 8 RETENTION_OPS_KEYS and points at ops/config (same shape as the #506 ceiling, #1609 proactive caps and ent#12 telemetry consent redirects), and ops/config type/range-validates every value all-or-nothing plus audit-logs `ops_settings_change` naming which windows moved. What this does NOT do, stated plainly because the asymmetry is counter-intuitive and it would be easy to oversell: * garbage always failed SAFE — unparseable coerces to 0, and 0 means "sweep disabled", i.e. retain forever; * a SMALL VALID INTEGER is the catastrophic input. "1" is well-typed, in range, and is exactly the issue's PoC. It is deliberately still ACCEPTED, because no range check can separate it from an operator who genuinely wants a one-day window. So validation is not the control that stops the attack — the admin gate (#1890) and the #1644 blast-radius guard are. What it buys is a loud boundary failure instead of a silent coercion to a value nobody chose (#1525), and the removal of the second, entirely unvalidated path. The audit half is its own small fix: the one route that could shrink a retention window was also the one that left no trace of having done it, which ent#297 lists in its blast radius. The #1039 community floor is deliberately NOT clamped here — it is a fresh-install seed plus an enterprise entitlement clamp, never an OSS hard limit (#1638), and silently rewriting an admin's explicit 3 into a 5 is the same class of invisible mutation #1638 was about. Verification: 34 unit tests (structural + behavioural through the real handlers, with an explicit non-MagicMock admin principal per the #1816 trap), plus HTTP-level via TestClient against the real router — generic PUT retention window -> 422 (the PoC call, blocked) generic PUT normal key -> 200 (no collateral damage) ops/config garbage -> 422 ops/config negative -> 422 ops/config valid -> 200 ops/config ssh toggle (UI) -> 200 (the only live UI caller of this route) 352 passed across the settings/retention/ops/1638/1644/1709 selection. Related to Abilityai/trinity-enterprise#297 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ms (ent#297) Self-review findings on this PR: 1. The upper bound was invented, not adopted. `_DAYS_MAX = 36500` (100y) while the enterprise `retention` module validates the SAME windows at `le=3650` (10y). Two validated write paths with two different contracts for one value: an admin could store a window through the OSS route that the managed panel then refuses to edit — surfaced by its own GET, rejected by its own PUT. Aligned to 3650 and pinned by a test, since the enterprise constant can't be imported (private submodule; OSS must build without it). 2. "the seven retention windows" — there are EIGHT. `agent_reminders_retention_days` (#1296) joined RETENTION_OPS_KEYS and my prose didn't. Said in three places. 3. "this is the ONLY write path" — false. The enterprise `PUT /api/enterprise/retention/config` is a second one, and it was already typed and range-validated, so the security claim was never load-bearing on it. Reworded to what is actually true: this is the OSS write path. Noted while checking (3), not fixed here because it is enterprise-side: that endpoint's `RetentionConfigUpdate` covers 7 of the 8 windows — `agent_reminders_retention_days` is absent, so the managed panel cannot set it. Related to Abilityai/trinity-enterprise#297 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/review Report — #1890 + #1893 (the ent#297 pair)Branches: Self-review, so weighted toward finding problems — my self-review of #1885 earlier this week missed that I'd reintroduced the exact duplication that issue forbade. Four findings, all fixed. Critical[C1] Auth boundary: a THIRD admin-gate spelling left open (Confidence: 10/10) — FIXED File: @router.post("/{agent_name}/circuit-breaker/reset")
async def reset_circuit_breaker_endpoint(
agent_name: AuthorizedAgentByName,
current_user: User = Depends(require_role("admin")), # <- not require_adminand the reason it stayed open ( def _require_role(current_user: User = Depends(get_current_user)) -> User:
_reject_connector_principal(current_user) # connector, yes
# reject_agent_principal — absentIssue. #1890 closes The naive fix is wrong, which is why this needed checking rather than patching: adding Fix applied: swap the one call site to Pinned by an AST scan over Informational[I1] #1893 invented a bound instead of adopting the existing one (Confidence: 9/10) — FIXED
execution_row_retention_days: Optional[int] = Field(None, ge=0, le=_MAX_DAYS) # _MAX_DAYS = 3650Two validated write paths, two different contracts for one value: an admin could store a window via the OSS route that the managed panel then refuses to edit — surfaced by its own GET, rejected by its own PUT. Aligned to 3650 with a test pinning it, since the enterprise constant can't be imported (private submodule; OSS must build without it). This is the duplicated-policy-constant drift class from [I2] Two false claims in #1893's own prose (Confidence: 10/10) — FIXED
[I3] #1890's gate docstrings undercounted the class (Confidence: 8/10) — FIXED Both said "three consecutive incidents". ent#297 traced five (ops-agent#232, #1644, #1816, ent#236, ent#293), against 18 bolt-ons and 114 admin-gated call sites. In a security docstring the count is the argument for why the gate moved, so an undercount weakens the reasoning a future reader inherits. [I4] Enterprise panel can't set one of the eight windows (Confidence: 8/10) — NOT FIXED, enterprise-side
Clean (verified, cited)
Summary
The one that mattered was C1, and it is worth naming why it survived the first pass: I audited the gates I changed rather than enumerating every way an admin gate can be spelled. Same shape as the #1871 lesson in |
…ards (ent#297) The durable parts: a class-closing fix must enumerate every spelling of the gate (a deliberately-permissive sibling helper is where the class survives, and its permissiveness must be documented AT the helper); a source-scanning guard must parse the AST because the comment explaining what not to write contains the offending string; and a new validation bound should adopt the one another module already applies to the same value, not invent a second contract.
|
Resolve by running |
…ings-hardening # Conflicts: # docs/memory/learnings.md
…ings-hardening # Conflicts: # docs/memory/learnings.md # src/backend/routers/settings.py # tests/registry.json
vybe
left a comment
There was a problem hiding this comment.
/validate-pr — approved.
All green including the pytest matrix (which the four required checks on dev don't cover, so worth naming). Security scans clean; no new os.getenv, no schema change, no BaseModel under routers/ (Invariant #14). 22 test defs (34 with parametrize) named for the issue, driven through the real handlers with a dataclass admin principal rather than a MagicMock — the #1816/ent#293 trap. except HTTPException: raise correctly added so the 422 isn't swallowed into a 500. The parity guards against RETENTION_OPS_KEYS/OPS_SETTINGS_DEFAULTS drift are the right shape.
One discrepancy, not a blocker. The PR body says "/ops/config and /ops/reset logged nothing", and the architecture.md prose this PR adds says "neither this route nor /ops/reset logged anything before" — but only /ops/config got the audit call. reset_ops_settings still deletes ops-setting rows, including ssh_access_enabled, with no audit entry. The retention claim holds (reset correctly skips RETENTION_OPS_KEYS); the audit claim as written doesn't. Filing a follow-up rather than holding the PR — the retention half is the part ent#297 needed.
Also: _DAYS_MAX = 3650 is aligned to the enterprise _MAX_DAYS by value plus a comment because the constant can't cross the submodule seam. That's the exact drift class this PR names as the repo's recurring failure — the parity assertion can only live on the enterprise side.
Cross-tracker ref, so the automation can't fire: setting status-in-dev on ent#297 manually after merge.
ent#297 / PR #1893 added validation **and** an audit entry to `PUT /api/settings/ops/config`, and its prose claimed the audit half covered both routes: "Neither this endpoint nor /ops/reset logged anything, while the generic PUT /{key} directly above them does…" `/ops/reset` never got one. So the exact asymmetry ent#297 objected to survived one route over: the generic `PUT /{key}` audits, `/ops/config` audits, and `/ops/reset` — admin-only, deleting a row per key in `OPS_SETTINGS_DEFAULTS` — left no trace at all. The retention half of that prose does hold: reset `continue`s over `RETENTION_OPS_KEYS` (#1638), so it cannot shrink a retention window. What it could silently revert unlogged is everything else, including `ssh_access_enabled` — the setting that decides whether ephemeral SSH credentials can be minted at all. Two choices worth stating, since neither is a straight copy of the `/ops/config` call it mirrors: * **Keys and counts only, no values.** Every one of these rows is being DELETED, so the durable fact is which keys reverted to their code default and which were protected — not what they held on the way out. * **Logged unconditionally**, not gated on having deleted something the way `/ops/config` gates on `updated`. There, an empty set means nothing was asked for; here, an admin resetting already-default settings is a real administrative act whose absence from the log is indistinguishable from it never having been attempted — this issue's own reporting gap, in miniature. The action name is deliberately distinct (`ops_settings_reset` vs `ops_settings_change`): setting values and deleting rows are different acts, and sharing a name would make them indistinguishable in the very log built to tell them apart. `architecture.md` gains the clause that makes its existing sentence discoverable rather than merely true. tests/unit/test_1966_ops_reset_audit.py — 11 checks, 9 of which fail against the pre-fix tree. Structural assertions are scoped to the reset handler's own source slice, since `/ops/config`'s audit call sits ~30 lines above it and a whole-file grep would pass on that instead. Related to #1966 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
What
The retention windows that drive irreversible deletion now have exactly one API write path, and it validates and audits.
PUT /api/settings/{key}db.set_setting, no checks/ops/configPUT /api/settings/ops/configDict[str, str]written straight through, no auditWhy
ent#297 (private tracker) is the root-cause issue for a class that has been point-fixed five times: an agent-scoped MCP key resolves to its owner carrying the owner's role, so on a default admin-owned install every agent's injected
TRINITY_MCP_API_KEYsatisfied every admin gate — 114 of them.The root fix is #1890, which puts
reject_agent_principalinsiderequire_adminandassert_admin(ent#297 AC 1/2/3/6). This PR is the other half — AC #4 and #5, the retention windows the attack actually reached.What this does NOT do
Stating this plainly because the asymmetry is counter-intuitive and easy to oversell:
max(int(raw), 0)in a try/except returning0, and0means sweep disabled — so"abc"widened retention to forever.{"execution_row_retention_days": "1"}is well-typed, in range, and is exactly the issue's PoC. It is deliberately still accepted, because no range check can separate it from an operator who genuinely wants a one-day window.So validation is not the control that stops the attack — the admin gate (#1890) and the #1644 blast-radius guard are. What it buys is a loud boundary failure instead of a silent coercion to a value nobody chose (#1525), and the removal of the second, entirely unvalidated path.
The audit half is its own small fix:
/ops/configand/ops/resetlogged nothing, while the genericPUT /{key}immediately above them does. The one route that could shrink a retention window was the one route that left no trace of having done it — ent#297 lists the audit surface in its blast radius.Deliberately not clamped to the community floor
0stays valid on every window (documented "disable this sweep"), and an explicit3is not rewritten to5. The #1039 floor reaches installs by seeding fresh ones plus an enterprise entitlement clamp — never an OSS hard limit (#1638). Silently rewriting an admin's explicit choice is the same class of invisible mutation #1638 was about.Verification
34 unit tests — structural and behavioural through the real handlers, with an explicit dataclass admin principal rather than a
MagicMock(whose truthy.agent_namereads as an agent key — the trap recorded in #1816 and re-hit in ent#293).HTTP-level via
TestClientagainst the real router:I checked the callers before blocking anything: the Settings UI writes retention only through the enterprise
PUT /api/enterprise/retention/config, and the sole UI use of/ops/configis thessh_access_enabledtoggle. Neither regresses.Two parity guards included, because this repo's recurring failure is a value set defined in one place and consumed in another that drifts: a new
RETENTION_OPS_KEYSentry or a new ops setting without a validation spec fails the build.352 passed across the settings / retention / ops / #1638 / #1644 / #1709 selection.
Related to abilityai/trinity-enterprise#297