team_manifest.py keeps a per-team version high-water mark
(team/manifest_highwater.json) specifically to stop a superseded manifest being
replayed. leave() goes out of its way to preserve it, with the reason stated in
its own docstring: "monotonicity must survive leave() so a superseded manifest
can't be replayed on re-join to restore a removed member."
That check lives only in adopt(). current() — the function every Teams consumer
calls to obtain the authoritative roster — reads the file and calls
verify_manifest(), which explicitly does not check it: "Does NOT check version
monotonicity — that is adopt()'s job because it needs the local store."
Nothing re-applies the check on the read path. Writing an older, genuinely
root-signed manifest over team/manifest.json therefore bypasses adopt()
entirely, and the roster it describes becomes authoritative. No forgery is
involved: the old document is signed by the real team root and verify_manifest()
accepts it on its own merits.
Reproduction steps:
- Mint a team root and two manifests over the same root:
v1 with an owner plus approver A, v2 with A removed.
- adopt(ws, v1) then adopt(ws, v2). Confirm the roster change took:
current(ws)["version"] -> 2
member(ws, A) -> None
has_role(ws, A, "approver") -> False
- Confirm the replay protection works on the adopt() path:
adopt(ws, v1) -> (False, "rejected: version 1 <= persisted high-water
mark 2 ... (manifest replay)")
- Write the v1 document straight to team/manifest.json (the path
_manifest_path(ws) returns), without calling adopt().
- Re-read the roster:
current(ws)["version"]
member(ws, A)
has_role(ws, A, "approver")
_get_highwater(ws, team_id)
Expected:
The roster must not roll back. A document whose version is at or below the
recorded high-water mark should not become authoritative, whichever path it
arrives by — that is the property the high-water file exists to provide, and it
is the property adopt() enforces one function away.
Actual:
STEP 2 current version: 2 member(A): None has_role(A,'approver'): False
STEP 3 adopt(v1) -> (False, 'rejected: version 1 <= persisted high-water
mark 2 for team tm_… (manifest replay)')
STEP 4 wrote v1 over the adopted v2
STEP 5 current version: 1
member(A): ['approver']
has_role(A,'approver'): True
high-water still says: 2
The removed approver is an approver again, while the high-water file on disk
still records version 2 — the evidence needed to reject the document was
present and simply not consulted.
Root cause:
primitives/team_manifest.py — _get_highwater()/_bump_highwater() are referenced
only inside adopt(). current() calls verify_manifest(doc) and returns the
document if the structural and root-signature checks pass; verify_manifest()
documents that monotonicity is out of scope for it. So the read path has no
monotonicity check at all.
This matters because current() is the authority for the whole Teams subsystem:
team_mesh (envelope member/role verification), team_approval (who may approve
and toward quorum), team_jobs, team_share (grant issuance), team_policy, and
routes/team.py all call it.
Suggested fix:
Apply the same check on read. In current(), after verify_manifest() succeeds,
reject a document whose version is below the recorded high-water mark:
ok, _ = verify_manifest(doc)
if not ok:
return None
if int(doc.get("version") or 0) < _get_highwater(ws, doc["team_id"]):
return None # superseded manifest on disk — do not become authority
Returning None (the same as "no manifest adopted") keeps the failure closed for
every consumer. Consider also raising the high-water mark to the adopted
version on every successful read, so a rollback cannot be laundered through a
restart, and surfacing the mismatch in the Team tab rather than failing silently.
Honest scope:
This requires local write access to the workspace's team/manifest.json (0600,
same user), the same threat model as other accepted findings against this
station's on-disk guarantees. It is not remote and not unauthenticated. What it
defeats is a control the codebase added deliberately, for exactly this attack,
and then applied on only one of the two paths that decide the roster.
The attacker must possess a genuinely root-signed older manifest. That is a
realistic artifact rather than an exotic one: it is whatever was on disk before
the roster change, and it survives in backups, snapshots, config management and
the team's own distribution channel. No root key and no forgery is needed.
I am not claiming this alone executes an effect. It restores a removed member's
roles, which the Teams gates then honour; whether that leads to an approval
depends on the quorum configuration and on approvals still being solicited.
Counter-evidence checked:
- Confirmed the protection genuinely works on the adopt() path in the same run,
so this is an inconsistency between two paths and not a missing feature.
- Confirmed via the real mint_root()/mint_manifest()/adopt()/current() functions
from a clean tarball extraction, with real Ed25519 root signatures rather than
hand-built documents.
- Confirmed verify_manifest() states the omission itself, so the read path's lack
of a check is not compensated inside it.
- Confirmed _get_highwater/_bump_highwater have no callers outside adopt().
- Confirmed the high-water value is still correct after the rollback, ruling out
the alternative explanation that the mark was clobbered by the file write.
Distinctness:
This is the read path (current()) skipping monotonicity, not the adopt path.
It is distinct from the earlier fixed finding that leave() used to delete the
only record of an adopted version — that fix created the high-water file and
hardened adopt()/leave(); this is that same protection never being applied in
current(). It is distinct from the reported finding that a live team gate trusts
a stored approved status after the roster changes: that concerns a stored
approval surviving a legitimate roster change, whereas this rolls the roster
itself back, and neither fix addresses the other. I did not find a community
thread about current() ignoring the high-water mark.