mesh(safety): mirror per-issuer fairness cap in resume replay cache#341
mesh(safety): mirror per-issuer fairness cap in resume replay cache#341cagataycali wants to merge 3 commits into
Conversation
_on_safety_resume added to its replay cache unconditionally while _on_safety_estop bounds each issuer to max(1, _resume_replay_cache_max() // 4) slots. A compromised peer holding the override code could fill the global resume cache and evict legitimate other-issuer entries via the oldest-20-percent drop branch of _evict_replay_cache, suppressing replay rejection signals. Mirror the estop cap in _on_safety_resume: count slots owned by the domain-tagged issuer_key and refuse the cache slot above cap, emitting a resume_per_issuer_cap_exceeded audit. The lockout still clears so a legitimate resume is preserved even when the cache cannot hold it. Adds tests/mesh/test_resume_per_issuer_cap.py with three pins. closes strands-labs#266
yinsong1986
left a comment
There was a problem hiding this comment.
Summary
This PR closes #266 by mirroring the per-issuer fairness cap that _on_safety_estop already enforces (max(1, _resume_replay_cache_max() // 4)) into _on_safety_resume. Before the fix, a peer holding the override code could fill the global resume replay cache and then evict legitimate other-issuer entries via the oldest-20%-drop branch of _evict_replay_cache, suppressing future replay-rejection signals. The fix counts existing slots owned by the same issuer_key (domain-tagged ('wire', wire_zid) or ('body', issuer_id)) after eviction, refuses the cache insert above cap, and emits a new resume_per_issuer_cap_exceeded audit event. The lockout-clear path remains unconditional so a legitimate resume is not lost when the cache is at cap. Three regression tests are added.
Diff is small (+44 / -1 in core.py, +127 new test file), all mutation stays inside the existing self._resume_replay_lock, the new audit event_type is purely additive (no consumer-breaking schema change), and the change is structurally consistent with the estop branch at lines 1745--1806.
What's good
- Tightly scoped to the documented threat; no incidental refactors.
- Mutation stays inside
self._resume_replay_lock, matching AGENTS.md > Review Learnings (PR #85) > 'Thread Safety'. - Audit-publish
try/exceptuses the narrow(TypeError, ValueError, OSError)tuple consistent with sibling sites at lines 1799 and 2098 (AGENTS.md > Review Learnings (PR #86) > 'Exception Clauses Must Be Narrow'). - The lockout-clear path is preserved on cap rejection, so over-cap does not silently drop a legitimate resume; this is called out both in code comments and the PR description.
- New event_type
resume_per_issuer_cap_exceededis additive --- no wire/persisted-format break, no one-way door. issuer_slotsis computed after_evict_replay_cacheruns, so a paced flooder who waits past TTL legitimately reclaims slots (matches the documented dynamic-attacker rate-limit semantics in the estop branch at lines 1764--1774).
Must fix before merge
(none --- PR is ready to merge once the follow-ups are tracked as v0.4.1 issues)
Follow-up in v0.4.1
- Audit payload omits forensic fields (
strands_robots/mesh/core.py:2148-2151). The over-cap audit emits only{issuer, cap}, while the siblingresume_replay_rejectedat line 2090-2096 includesproof_nonce_prefixandestop_per_issuer_cap_exceededat line 1791-1797 includesissuer_t. Operators correlating these audits across the freshness window have less to go on. Suggest addingissuer_t(envelopet) andproof_nonce_prefix(first 16 chars) to the resume payload. - Audit
issuermisattributes when wire_zid is present (core.py:2149). The fairness key isissuer_key = ('wire', wire_zid) if wire_zid is not None else ('body', issuer_id), but the audit reports bodyissuer_idonly. An attacker on one TLS-bound session who spoofs a varying bodypeer_idper envelope generates audits with shifting attribution, while the actual offender (one wire session) is invisible. Addwire_zid(or the fullissuer_keyshape) to the payload so operator dashboards can pivot on the real fairness key. This is pre-existing in the estop audit too --- worth fixing both. test_resume_and_estop_caps_matchpins source-string instead of behaviour (tests/mesh/test_resume_per_issuer_cap.py:120-127). It does literalinspect.getsource()substring matching for the formulaper_issuer_cap = max(1, _resume_replay_cache_max() // 4). Any benign refactor (extracting the formula into a helper, renaming the local) breaks the test without a behavioural change. Per AGENTS.md > 'Test behavior, not implementation', prefer a runtime parity check: monkeypatchSTRANDS_MESH_RESUME_REPLAY_CACHE_MAXto a few values, instrument both handlers (e.g. viacaplogon the warning that already echoescap %d), and assert the runtime cap value matches between the two. The intent --- symmetry --- is correct; the implementation pins the wrong layer.- The eviction-prevents-eviction test never exercises the eviction path (
tests/mesh/test_resume_per_issuer_cap.py:90-117). WithSTRANDS_MESH_RESUME_REPLAY_CACHE_MAX=8andcap=2, the cache holds at most1 (legit) + 2 (attacker) = 3entries --- well belowmax_size, so_evict_replay_cache's oldest-20%-drop branch (lines 216-220) never fires. The test demonstrates the cap mechanism but does NOT pin the actual pre-fix threat (cache-fill-then-evict-legit). Suggest adding a third test that (a) without the cap, fills pastmax_sizewith attacker entries and confirms the legit entry IS evicted by the 20%-drop, and (b) with the cap, repeats the attack and confirms the legit entry survives. That's the regression pin per AGENTS.md > Review Learnings (PR #86) > 'Pin every reviewed fix with a regression test'. - Documented symmetry overstates the structural match. Comments on
core.py:2119-2127say the cap is symmetric with the estop branch. That is true for the cap value, but the bookkeeping shapes differ: estop countsissuer == issuer_idover itsdict[float, tuple[issuer_id, mono_ts, wire_zid]](body-id only), whereas resume countscached_issuer == issuer_keyover itsdict[(issuer_key, proof_nonce), float](wire-aware). The choice is correct given each cache's key shape, but the comment is misleading. Tighten to 'symmetric per-issuer cap value; bookkeeping shapes differ because the two caches store different keys'.
Verification suggestions
hatch run test tests/mesh/test_resume_per_issuer_cap.py -v--- confirms the 3 new pins pass and the 14 existing resume-replay tests still pass.- Spot-check that
event_type="resume_per_issuer_cap_exceeded"appears in any consumer of safety events (operator dashboards, IoT bridge audit log) so the new event is rendered, not dropped as 'unknown'. A grep forestop_per_issuer_cap_exceededacross consumer code shows whether existing handling is event-type-agnostic; if it special-cases known event_types, add the new one. - Optional manual check: with
STRANDS_MESH_RESUME_REPLAY_CACHE_MAX=4(cap=1), bring up two test peers, drive a flood from one, and observe in logs that the second peer's resume cache slot is preserved while the first peer is throttled at 1 slot.
| payload={ | ||
| "issuer": issuer_id, | ||
| "cap": per_issuer_cap, | ||
| }, |
There was a problem hiding this comment.
[FOLLOW-UP] Audit payload is leaner than its siblings. resume_replay_rejected (line 2090-2096) emits proof_nonce_prefix; estop_per_issuer_cap_exceeded (line 1791-1797) emits issuer_t. Without issuer_t and a nonce prefix here, an operator correlating resume_per_issuer_cap_exceeded against the freshness-window range or against a specific captured envelope has nothing to pivot on. Suggest extending the payload to {"issuer": issuer_id, "issuer_t": envelope_t, "proof_nonce_prefix": proof_nonce[:16], "cap": per_issuer_cap} to match the rest of the safety-audit shape.
| event_type="resume_per_issuer_cap_exceeded", | ||
| severity="warning", | ||
| payload={ | ||
| "issuer": issuer_id, |
There was a problem hiding this comment.
[FOLLOW-UP] Audit reports body issuer_id, but the fairness key is issuer_key = ("wire", wire_zid) if wire_zid is not None else ("body", issuer_id) (line 2076). An attacker on a single TLS-bound wire session who varies the body peer_id per envelope will trigger audits whose issuer field shifts every call, while the actual offender (the wire zid) is invisible to operator dashboards. Suggest including wire_zid (or the full issuer_key tuple, shape ["wire"|"body", str]) in the payload so dashboards can group by the real fairness key. The estop audit at line 1791-1797 has the same misattribution surface --- worth fixing both in the same follow-up.
| # drop branch of _evict_replay_cache. The cap is derived | ||
| # from the SAME formula as _on_safety_estop so the two | ||
| # caches stay symmetric (see issue #266). | ||
| per_issuer_cap = max(1, _resume_replay_cache_max() // 4) |
There was a problem hiding this comment.
[FOLLOW-UP] The comment at lines 2119-2127 claims the cap is symmetric with _on_safety_estop. That is true for the cap value, but the bookkeeping shapes are not symmetric: estop's per-issuer count at line 1775 iterates the value tuples (issuer == issuer_id, body-only), whereas this loop iterates the keys (cached_issuer == issuer_key, wire-aware). Both are correct given each cache's key shape, but readers comparing the two will wonder why the iteration looks different. Tighten the comment to 'symmetric cap value; bookkeeping differs because the two caches use different key shapes'.
| ) | ||
| assert attacker_slots <= cap | ||
| # The legitimate entry survived the attacker's churn. | ||
| assert legit_key in m._resume_replay_cache |
There was a problem hiding this comment.
[FOLLOW-UP] This test is named 'prevents_eviction_of_other_issuers' but never exercises the eviction path. With STRANDS_MESH_RESUME_REPLAY_CACHE_MAX=8 and cap=2, the cache holds at most 1 + 2 = 3 entries, far below max_size=8, so _evict_replay_cache's oldest-20%-drop branch (core.py:216-220) never fires --- the legit entry survives by definition, with or without the per-issuer cap. To actually pin the original threat, add a counter-test that runs the same churn against a hypothetical no-cap implementation (e.g. by patching per_issuer_cap to a huge number, or by inserting directly into _resume_replay_cache) and asserts the legit entry IS evicted, then runs the real path and asserts it survives. That's the regression pin AGENTS.md > Review Learnings (PR #86) > 'Pin every reviewed fix with a regression test' is asking for.
| src = inspect.getsource(Mesh._on_safety_resume) | ||
| estop_src = inspect.getsource(Mesh._on_safety_estop) | ||
| formula = "per_issuer_cap = max(1, _resume_replay_cache_max() // 4)" | ||
| assert formula in src |
There was a problem hiding this comment.
[FOLLOW-UP] This pins the formula's literal source string in both functions via inspect.getsource(). Any benign refactor --- extracting _per_issuer_cap() into a helper, renaming the local, splitting the max(1, ...) onto two lines --- breaks this test without changing behaviour. Per AGENTS.md > Key Conventions #9 ('Test behavior, not implementation'), test the runtime cap value instead: monkeypatch STRANDS_MESH_RESUME_REPLAY_CACHE_MAX to a few values, drive both _on_safety_estop and _on_safety_resume past their cap, and assert the cap %d value reported in the warning log (or the audit payload) matches between the two handlers for each setting. Pins the symmetry property without locking the implementation.
Whitespace-only normalization to resolve the format --check failure. No logic change.
yinsong1986
left a comment
There was a problem hiding this comment.
Summary
Mirrors the per-issuer fairness cap from _on_safety_estop into _on_safety_resume so a single compromised issuer can no longer fill the global resume replay cache and evict legitimate other-issuer entries via the oldest-20-percent drop branch of _evict_replay_cache. Cap formula (max(1, _resume_replay_cache_max() // 4)) is identical to the estop side, the over-cap rejection emits a resume_per_issuer_cap_exceeded audit, and the lockout-clear is preserved on rejection so a legitimate resume is not dropped even when the cache cannot hold it. Three new pins + 14 existing tests pass.
Scope is tight, the change is well-commented, audit-publish narrow except follows AGENTS.md, and the lockout-still-clears branch matches the documented estop semantics. Nothing here blocks v0.4.0 / Summit.
What's good
- Symmetric cap formula and
else:-add structure makes the two handlers genuinely parallel. - Comment block explicitly cites issue #266 and explains why the cap protects the oldest-20-percent eviction path -- future readers do not need to re-derive the threat model.
except (TypeError, ValueError, OSError)matches the existing audit-publish guard at line 1816 -- no broadexcept Exceptionregression (AGENTS.md > Review Learnings #86 > 'Exception Clauses Must Be Narrow').- Lockout-clear is preserved on cap rejection: a legitimate resume is never dropped on the safety path even when the replay-cache slot is refused. Documented in the inline comment.
- Three new tests cover the cap-enforced case, the eviction-prevention case, and a structural pin that the formulas match.
Must fix before merge
(none -- PR is ready to merge once the follow-ups below are tracked as v0.4.1 issues)
Follow-up in v0.4.1
- Counting-key asymmetry between estop and resume. Estop counts cache slots by
issuer_idonly (the bodypeer_id, seecore.py:1792). The new resume code counts byissuer_key-- the domain-tagged tuple("wire", wire_zid)or("body", issuer_id). The PR description claims the caches stay "symmetric" but the counting key differs. In practice the resume key is arguably better (resists wire/body namespace collisions) but the divergence should either be intentional and documented, or estop should be updated to match. File:strands_robots/mesh/core.py:2129. - Audit payload missing
issuer_t. The estop side emits"issuer_t": envelope_tinestop_per_issuer_cap_exceeded(core.py:1812) so an operator dashboard can correlate which envelope window was rejected; the newresume_per_issuer_cap_exceededpayload only carriesissuerandcap. Adding the field is additive (subscribers tolerate extra keys) so it is non-blocking. File:strands_robots/mesh/core.py:2147. test_resume_and_estop_caps_matchis a structural test, not a behavioural one. It greps the source of both handlers for an exact formula string. Any whitespace, comment, or refactor that splits the line will break the test even if behaviour is identical. AGENTS.md > Key Conventions #9 says "Test behaviour, not implementation" -- prefer a behavioural pin (e.g. assert that the same_resume_replay_cache_max() // 4cap value is enforced on both caches by exercising both handlers). File:tests/mesh/test_resume_per_issuer_cap.py:122.- No coverage for the
wire_zidbranch ofissuer_key. Both new tests drive the body-id path (("body", "attacker")); the wire branch (("wire", wire_zid)) -- which is the more security-relevant one because it binds to the TLS-bound zid -- is not exercised. A test that synthesises awire_zidand asserts the cap is keyed by it would close the gap. - Audit payload schema documentation.
resume_per_issuer_cap_exceededjoins the existingestop_per_issuer_cap_exceeded/resume_replay_rejectedfamily. If there is a list of audit event_types inmesh/audit.pyor the README that ops dashboards subscribe to, add this one in the same PR or a follow-up so subscribers know to handle it.
Verification suggestions
hatch run test tests/mesh/test_resume_per_issuer_cap.py -v
# spot-check the resume + estop caps stay aligned under combined load:
hatch run test tests/mesh/ -k 'replay or per_issuer'No integration coverage needed -- the change is purely in-process bookkeeping with no wire-format or hardware impact.
| # from the SAME formula as _on_safety_estop so the two | ||
| # caches stay symmetric (see issue #266). | ||
| per_issuer_cap = max(1, _resume_replay_cache_max() // 4) | ||
| issuer_slots = sum(1 for cached_issuer, _nonce in self._resume_replay_cache if cached_issuer == issuer_key) |
There was a problem hiding this comment.
[FOLLOW-UP] Counting-key asymmetry vs estop. Estop counts slots by the bare issuer_id (core.py:1792: if issuer == issuer_id), but this counts by the domain-tagged issuer_key tuple (("wire", wire_zid) or ("body", issuer_id)). The PR description and the inline comment both say the caches are "symmetric" / mirror estop, but the counting key actually differs. The resume choice is arguably better (it resists a wire-vs-body namespace collision attack), but the divergence should either be intentional + documented, or estop should be updated to match. Track as a v0.4.1 alignment issue.
| event_type="resume_per_issuer_cap_exceeded", | ||
| severity="warning", | ||
| payload={ | ||
| "issuer": issuer_id, |
There was a problem hiding this comment.
[FOLLOW-UP] Audit payload is missing issuer_t -- the corresponding estop_per_issuer_cap_exceeded audit at core.py:1812 includes "issuer_t": envelope_t so an operator dashboard can correlate which envelope window was rejected. Adding the field here is additive (subscribers tolerate extra keys) so it is non-blocking, but the asymmetry reduces forensic value of the over-cap signal -- the whole point per the PR description is symmetric handling. Suggest:
payload={
"issuer": issuer_id,
"issuer_t": envelope_t,
"cap": per_issuer_cap,
},| formula, keeping the two handlers symmetric (issue #266).""" | ||
| src = inspect.getsource(Mesh._on_safety_resume) | ||
| estop_src = inspect.getsource(Mesh._on_safety_estop) | ||
| formula = "per_issuer_cap = max(1, _resume_replay_cache_max() // 4)" |
There was a problem hiding this comment.
[FOLLOW-UP] This test asserts an exact source-substring match for the cap formula. AGENTS.md > Key Conventions #9 says "Test behaviour, not implementation". Any whitespace edit, line split, or comment insertion in either handler will break this even when the behaviour is identical -- the test pins a string, not a property.
A behavioural alternative: drive both handlers up to the cap, assert both refuse the (cap+1)-th distinct envelope from the same issuer, and assert both compute the cap as _resume_replay_cache_max() // 4 for several distinct values via monkeypatch.setenv. That actually fails on a future change that breaks symmetry, instead of failing on a future cosmetic edit that does not.
| def test_resume_cache_cap_prevents_eviction_of_other_issuers(monkeypatch): | ||
| """An over-cap issuer cannot add slots, so legitimate other-issuer | ||
| entries already in the cache are never evicted by the attacker's | ||
| churn.""" |
There was a problem hiding this comment.
[FOLLOW-UP] Neither new test exercises the ("wire", wire_zid) branch of issuer_key -- both drive the body-id path only. The wire branch is the more security-relevant one (it binds the cap to the TLS-bound zid rather than to user-controlled body metadata), and is exactly what the new code goes out of its way to handle distinctly. A regression that broke wire-side keying would not be caught by this test file. Track as a coverage-gap follow-up; not a merge blocker since the body path is covered.
yinsong1986
left a comment
There was a problem hiding this comment.
Summary
Mirrors the per-issuer fairness cap from _on_safety_estop into _on_safety_resume, so a single attacker holding the override code can no longer fill the global resume replay cache and trigger the oldest-20% eviction branch of _evict_replay_cache to drop legitimate other-issuer entries. Cap is max(1, _resume_replay_cache_max() // 4) — same formula as estop. Over-cap envelopes are refused a cache slot, an audit (resume_per_issuer_cap_exceeded) is emitted, and the lockout-clear path still runs so a legitimate resume isn't denied service. Three new pins land in tests/mesh/test_resume_per_issuer_cap.py alongside the 14 existing resume-replay tests.
Diff is small, contained, and the new code lives entirely inside the existing _resume_replay_lock critical section. Audit-publish exception handling matches the narrow (TypeError, ValueError, OSError) tuple convention from AGENTS.md > "Exception Clauses Must Be Narrow".
What's good
- Lock contract preserved — every read/write of
_resume_replay_cachestays insidewith self._resume_replay_lock:. - Audit-publish wrapped in narrow
except (TypeError, ValueError, OSError), with a debug log and the comment justifying the tuple — matches the estop handler exactly. - Failure mode is fail-open for the safety effect (lockout clears) and fail-closed for the cache slot (refused). Correct trade-off.
- Three regression pins, including a happy-path attacker-vs-legitimate-operator scenario that would catch a future regression of the eviction surface.
- Scope discipline — no drive-by changes to the estop handler or the eviction helper.
Must fix before merge
(none — PR is ready to merge once any follow-ups are tracked)
Follow-up in v0.4.1
- Test does not pin the lockout-clears-on-cap-rejection contract. The PR description explicitly states "the lockout still clears so a legitimate resume is preserved even when the cache itself cannot hold it", but
test_resume_cache_per_issuer_cap_enforcednever assertsm._estop_lockout.is_set() == Falseafter the over-cap call. A future refactor that turned the cap-rejection into an earlyreturnwould silently break this safety property and still pass CI. AGENTS.md > Review Learnings (#86) > "Pin every reviewed fix with a regression test". (tests/mesh/test_resume_per_issuer_cap.py:81) test_resume_and_estop_caps_matchis brittle to formatting. It string-matches the literal"per_issuer_cap = max(1, _resume_replay_cache_max() // 4)"ininspect.getsource(...). Any line-length tweak, trailing comment, or ruff reformat (e.g. parentheses split across lines) breaks the test without a behavioural change. A behavioural test — e.g. setSTRANDS_MESH_RESUME_REPLAY_CACHE_MAX=8and observe that both handlers refuse the third slot for the same issuer — would survive reformatting. (tests/mesh/test_resume_per_issuer_cap.py:122-123)- Audit payload missing forensic fields. The new
resume_per_issuer_cap_exceededaudit emits onlyissuerandcap. The parallelestop_per_issuer_cap_exceeded(line ~1799) emitsissuer,issuer_t,cap; the existingresume_replay_rejected(line ~2099) emitsissuerandproof_nonce_prefix. Operator-dashboard correlation across the audit log is materially harder without an envelope-time or nonce-prefix correlator on this event. (strands_robots/mesh/core.py:2153-2156) - Docstring/comment claim of "mirrors estop" overstates symmetry. Resume counts slots by
issuer_key— i.e. the wire-tagged tuple("wire", zid)when TLS-bound, falling back to("body", issuer_id). Estop at line 1782 counts only by the bodyissuer_id, which is forgeable by a same-session attacker varying their JSONpeer_id. Resume is actually stricter than estop here (good!), but the comment claims they're identical. Either tighten estop's count to also useissuer_key-style tagging in a follow-up, or update the comment to call out that resume is stricter by design. (strands_robots/mesh/core.py:2126-2134)
Verification suggestions
# Run the new pins plus the existing resume-replay suite:
hatch run test tests/mesh/test_resume_per_issuer_cap.py tests/mesh/ -k resume_replay -v
# Sanity-check that estop and resume both refuse the third slot when cap=2:
STRANDS_MESH_RESUME_REPLAY_CACHE_MAX=8 hatch run test \
tests/mesh/test_resume_per_issuer_cap.py tests/mesh/test_safety_estop_replay.py| # cache slot (over-cap) -- cache size does not grow. | ||
| m._estop_lockout.set() | ||
| m._on_safety_resume(_sample(_make_envelope("secret", peer_id="attacker"))) | ||
| assert len(m._resume_replay_cache) == cap |
There was a problem hiding this comment.
[FOLLOW-UP] The PR description says "the lockout still clears so a legitimate resume is preserved even when the cache itself cannot hold it", and the diff comment at core.py:2146-2148 makes the same claim. This test asserts the cache-size and audit-emission halves of that contract but does NOT assert the lockout-clear half. After the over-cap call on line 80, add:
assert not m._estop_lockout.is_set(), (
"over-cap rejection must still clear the lockout — see core.py:2146-2148"
)Without this, a future refactor that turned the cap-rejection branch into an early return would silently break the documented safety property and still pass CI. AGENTS.md > Review Learnings (#86) > "Pin every reviewed fix with a regression test".
| src = inspect.getsource(Mesh._on_safety_resume) | ||
| estop_src = inspect.getsource(Mesh._on_safety_estop) | ||
| formula = "per_issuer_cap = max(1, _resume_replay_cache_max() // 4)" | ||
| assert formula in src |
There was a problem hiding this comment.
[FOLLOW-UP] String-matching inspect.getsource(...) against the literal "per_issuer_cap = max(1, _resume_replay_cache_max() // 4)" is brittle to a benign reformat — a trailing comment, a noqa, or a ruff line-length pass that splits the call across lines all break the test without changing behaviour. A behavioural pin survives reformatting:
def test_resume_and_estop_caps_match(monkeypatch):
monkeypatch.setenv("STRANDS_MESH_RESUME_REPLAY_CACHE_MAX", "8")
# both handlers must refuse the third slot from the same issuer when cap=2
...Classified follow-up because the underlying behaviour IS pinned by the other two tests in this file; only the symmetry assertion is at risk.
| severity="warning", | ||
| payload={ | ||
| "issuer": issuer_id, | ||
| "cap": per_issuer_cap, |
There was a problem hiding this comment.
[FOLLOW-UP] The audit payload here is {"issuer": ..., "cap": ...}. The parallel estop_per_issuer_cap_exceeded audit at line ~1799 emits {"issuer", "issuer_t", "cap"}, and resume_replay_rejected at line ~2099 emits {"issuer", "proof_nonce_prefix"}. An operator dashboard correlating cap-exceeded events back to envelope timestamps or replay-rejected events on the same nonce loses that join key. Suggest:
payload={
"issuer": issuer_id,
"issuer_t": envelope_t,
"proof_nonce_prefix": proof_nonce[:16],
"cap": per_issuer_cap,
},Forensic-only — no behavioural impact, hence follow-up.
| # from the SAME formula as _on_safety_estop so the two | ||
| # caches stay symmetric (see issue #266). | ||
| per_issuer_cap = max(1, _resume_replay_cache_max() // 4) | ||
| issuer_slots = sum(1 for cached_issuer, _nonce in self._resume_replay_cache if cached_issuer == issuer_key) |
There was a problem hiding this comment.
[FOLLOW-UP] The comment block above (lines 2126-2134) says this "mirrors the estop handler" and the cap is "derived from the SAME formula as _on_safety_estop". The formula is identical, but the count semantics differ:
- Resume (this line): counts by
issuer_key, which is("wire", wire_zid)when TLS-bound, falling back to("body", issuer_id). - Estop (line 1782): counts purely by body
issuer_id— a same-session attacker varying their JSONpeer_idcan forge multiple distinct "issuers" and exceed the cap.
Resume is correctly the stricter of the two — this is a good thing — but the comment claims symmetry that doesn't fully hold. Either (a) update the comment here to note that resume is intentionally stricter, or (b) open a follow-up to tighten estop's count to use the same issuer_key tagging. AGENTS.md > Review Learnings (#86) > "Match docstrings to semantics".
|
Closing as redundant. The per-issuer fairness cap on the resume replay cache that this PR adds to per_issuer_cap = max(1, _resume_replay_cache_max() // 4)
issuer_slots = sum(1 for k in self._resume_replay_cache if k[0] == issuer_key)
if issuer_slots >= per_issuer_cap:
# ... resume_per_issuer_cap_exceeded audit, refuse slot ...This matches #342's estop-cap mirror exactly (same Follow-ups for the resume per-issuer cap (e.g. adding No loss of coverage: see #340 for the distinct domain-tag key-collision regression test (#264), which pins a different property and remains open. |
|
Closing as superseded. This PR (closes #266) mirrors the per-issuer fairness cap onto the resume replay cache. The same fix landed first via #342 (closes #267, merged in per_issuer_cap = max(1, _resume_replay_cache_max() // 4)
issuer_slots = sum(1 for k in self._resume_replay_cache if k[0] == issuer_key)
if issuer_slots >= per_issuer_cap:
... resume_per_issuer_cap_exceeded ...The two PRs converged on the identical formula and the same The non-blocking review follow-ups raised on this PR (forensic audit fields, behavioural-vs-source-string symmetry test, wire-zid branch coverage, lockout-clear-on-cap-rejection assertion, comment overstating estop symmetry) overlap with the merged-side tracker #365 and remain captured there for the v0.4.1 polish bundle. Closing #266 as resolved-by-#267. This comment was generated by an AI agent (Strands Agents). |
Summary
Closes #266.
_on_safety_resumeadded to its replay cache unconditionally, while_on_safety_estopbounds each issuer tomax(1, _resume_replay_cache_max() // 4)slots. A compromised peer holding the override code could fill the global resume cache and then evict legitimate other-issuer entries via the oldest-20-percent drop branch of_evict_replay_cache, suppressing replay-rejection signals.Fix
Mirror the estop per-issuer cap in
_on_safety_resume: count slots owned by the domain-taggedissuer_keyand refuse the cache slot above cap, emitting aresume_per_issuer_cap_exceededaudit. The lockout still clears so a legitimate resume is preserved even when the cache itself cannot hold it. The cap derives from the same formula as estop, keeping the two handlers symmetric.Tests
Added
tests/mesh/test_resume_per_issuer_cap.py:(3 new pins + 14 existing resume-replay tests)