Compare commits

..
Author SHA1 Message Date
peggy 7a2639d0f4 fix(#808): never borrow a session identity for non-tmux senders
ci/woodpecker/pr/ci Pipeline was successful
When MOSAIC_AGENT_NAME is unset and the sender has no tmux client context
($TMUX empty), agent-send.sh fell back to 'tmux display-message -p #S',
which outside tmux reports the LAST-ACTIVE session on the server. A
nameless sender was silently stamped as whichever seat touched tmux most
recently, forging that seat's identity in the preamble.

Incident (2026-08-20, host sb-it-1-dt): a nameless non-tmux sender was
stamped 'peggy', a live seat, on a message delivered TO peggy. Reproduced
end-to-end before the fix; capture in the seat evidence note.

Fix: the display-message fallback now runs only when $TMUX is set (inside
tmux it resolves the caller's own session — the case #808 built it for).
Outside tmux with no name, the label is host:unverified. Deliberate
identity remains available via -S. Chosen over refusing the send: nameless
senders are legitimate; the defect was borrowing an identity, not lacking
one.

Tests: suite made hermetic (TMUX controlled explicitly; results no longer
depend on the caller's context — previously tests 9/10 inherited the
caller's TMUX). New case 9b: no-tmux sender is labeled unverified and
never a borrowed name (red on original: host:local-agent borrowed from the
fake tmux server; green after). 19/19 in both caller contexts.
2026-08-20 15:41:48 -05:00
6 changed files with 36 additions and 146 deletions
@@ -54,22 +54,6 @@ def main(
arguments = parser.parse_args(argv)
source_environment = os.environ if environ is None else environ
# D29: a session that never held a lease has nothing to revoke, and that is a
# SUCCESS, not a failed revocation. The block below is deliberately fail-closed
# for a broker that is unreachable, which is right — but it cannot distinguish
# "the broker is down" from "there was never a lease", so a bare-launched
# session was denied every lifecycle transition, including compaction. Denying
# compaction protects nothing there; it converts a recoverable context limit
# into a lost session.
#
# Absence must be TOTAL to qualify. If exactly one variable is present the
# session is half-provisioned, which is real misconfiguration, and it still
# takes the fail-closed path below.
lease_variables = ("MOSAIC_LEASE_BROKER_SOCKET", "MOSAIC_LEASE_SESSION_ID")
present = [name for name in lease_variables if source_environment.get(name)]
if not present:
return 0
try:
if not arguments.reason or len(arguments.reason) > 128:
raise ValueError("invalid revoke reason")
@@ -126,7 +126,16 @@ if [ -z "$SRC_LABEL" ]; then
src_host=$(hostname -s 2>/dev/null || echo "?")
src_sess=${MOSAIC_AGENT_NAME:-}
if [ -z "$src_sess" ]; then
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
if [ -n "${TMUX:-}" ]; then
# Inside tmux: display-message resolves against this client's own session.
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
else
# Outside tmux with no name: display-message reports the LAST-ACTIVE
# session — someone else's identity (measured 2026-08-20: a nameless
# non-tmux sender was stamped "peggy", a live seat, forged silently).
# Stamp an explicit unverified label instead; deliberate senders use -S.
src_sess="unverified"
fi
fi
SRC_LABEL="${src_host}:${src_sess}"
fi
@@ -61,8 +61,15 @@ no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
# Run the tool with the stub injected; echoes captured payload on stdout.
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
run_auto() {
env -u MOSAIC_AGENT_NAME \
# Hermetic auto-label runs: TMUX is controlled explicitly so results never
# depend on whether the caller running this suite sits inside tmux.
run_auto() { # models a sender OUTSIDE tmux (no client context)
env -u MOSAIC_AGENT_NAME -u TMUX \
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
bash "$TOOL" -n dsthost "$@"
}
run_auto_in_tmux() { # models a sender INSIDE tmux (client context exists)
env -u MOSAIC_AGENT_NAME TMUX=/fake/socket \
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
bash "$TOOL" -n dsthost "$@"
}
@@ -145,7 +152,9 @@ want="[$src_host:authoritative-agent -> dsthost:mos] env identity"
|| no "MOSAIC_AGENT_NAME is authoritative across sockets" "got=[$got] want=[$want]"
# 9. Without the env identity, self-lookup uses local tmux, not destination -L.
got=$(FAKE_TMUX_MODE=sessions run_auto -L destination-socket -s mos -m "local fallback")
# Sender is INSIDE tmux: the only context where display-message self-lookup
# is safe (it resolves against this client's own session).
got=$(FAKE_TMUX_MODE=sessions run_auto_in_tmux -L destination-socket -s mos -m "local fallback")
want="[$src_host:local-agent -> dsthost:mos] local fallback"
[ "$got" = "$want" ] && ok "cross-socket fallback uses local sender session" \
|| no "cross-socket fallback uses local sender session" "got=[$got] want=[$want]"
@@ -153,8 +162,17 @@ want="[$src_host:local-agent -> dsthost:mos] local fallback"
&& ok "cross-socket fallback rejects destination holder identity" \
|| no "cross-socket fallback rejects destination holder identity" "got=[$got]"
# 9b. NO tmux context: display-message answers with the LAST-ACTIVE session —
# someone else's identity (forgery vector). The label must be `unverified`,
# never a borrowed name, even though a tmux server exists here and the fake
# would confidently answer `local-agent`.
got=$(FAKE_TMUX_MODE=sessions run_auto -s mos -m "no tmux context")
want="[$src_host:unverified -> dsthost:mos] no tmux context"
[ "$got" = "$want" ] && ok "no-tmux sender labeled unverified, never borrowed" \
|| no "no-tmux sender labeled unverified, never borrowed" "got=[$got] want=[$want]"
# 10. If neither env nor local tmux identifies the sender, preserve '?'.
got=$(FAKE_TMUX_MODE=unavailable run_auto -L destination-socket -s mos -m "unknown fallback")
got=$(FAKE_TMUX_MODE=unavailable run_auto_in_tmux -L destination-socket -s mos -m "unknown fallback")
want="[$src_host:? -> dsthost:mos] unknown fallback"
[ "$got" = "$want" ] && ok "unknown sender falls back to ?" \
|| no "unknown sender falls back to ?" "got=[$got] want=[$want]"
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh"
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
@@ -1,79 +0,0 @@
#!/usr/bin/env python3
"""D29 contracts: no lease is a no-op success; half-provisioned still fails closed."""
from __future__ import annotations
import importlib.util
import unittest
from pathlib import Path
TOOLS = Path(__file__).parents[2] / "framework/tools/lease-broker"
REVOKE_PATH = TOOLS / "revoke-lease.py"
_spec = importlib.util.spec_from_file_location("revoke_lease", REVOKE_PATH)
assert _spec and _spec.loader
revoke_lease = importlib.util.module_from_spec(_spec)
import sys as _sys
_sys.path.insert(0, str(TOOLS))
_spec.loader.exec_module(revoke_lease)
ARGV = ["--runtime", "claude", "--reason", "pre-compact"]
VALID_SESSION = "a" * 64
def _explode(*_args, **_kwargs):
raise AssertionError("broker must not be contacted when no lease is held")
class RevokeWithoutLease(unittest.TestCase):
def test_no_lease_variables_is_a_noop_success(self) -> None:
"""The D29 case: bare-launched session, nothing to revoke, must not deny."""
self.assertEqual(
revoke_lease.main(ARGV, environ={}, request=_explode),
0,
)
def test_no_lease_does_not_contact_the_broker(self) -> None:
"""A no-op must be vacuous: no socket, no generation bump, no transport."""
revoke_lease.main(ARGV, environ={"HOME": "/nonexistent"}, request=_explode)
def test_socket_without_session_still_fails_closed(self) -> None:
"""Half-provisioned is misconfiguration, not absence. Fail-closed stands."""
self.assertEqual(
revoke_lease.main(
ARGV,
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/tmp/nonexistent.sock"},
request=_explode,
),
2,
)
def test_session_without_socket_still_fails_closed(self) -> None:
"""The mirror case, so the guard cannot be satisfied by either half alone."""
self.assertEqual(
revoke_lease.main(
ARGV,
environ={"MOSAIC_LEASE_SESSION_ID": VALID_SESSION},
request=_explode,
),
2,
)
def test_empty_string_counts_as_absent(self) -> None:
"""An exported-but-empty variable is not a lease."""
self.assertEqual(
revoke_lease.main(
ARGV,
environ={
"MOSAIC_LEASE_BROKER_SOCKET": "",
"MOSAIC_LEASE_SESSION_ID": "",
},
request=_explode,
),
0,
)
if __name__ == "__main__":
unittest.main()
@@ -337,13 +337,7 @@ class ExecutableEntrypointTest(unittest.TestCase):
runpy.run_path(str(TOOLS_DIR / "launch-runtime.py"), run_name="__main__")
self.assertEqual(raised.exception.code, 64)
def test_revoker_entrypoint_noops_when_identity_environment_is_absent(self) -> None:
# D29 supersession. This assertion previously pinned rc=2. Absent identity
# means no lease was ever held, so there is nothing to revoke and the correct
# result is no-op success. The old pin was written in e4d7d45 (WI-3), the same
# commit that shipped launch-runtime.py's lease-var provisioning, on the
# assumption that an envless revoker was unreachable. D29 falsified that in
# production. Behavioural pins live in src/lease-broker/revoke_noop_unittest.py.
def test_revoker_entrypoint_denies_when_identity_environment_is_absent(self) -> None:
with patch.object(
sys,
"argv",
@@ -358,42 +352,9 @@ class ExecutableEntrypointTest(unittest.TestCase):
io.StringIO()
), self.assertRaises(SystemExit) as raised:
runpy.run_path(str(TOOLS_DIR / "revoke-lease.py"), run_name="__main__")
self.assertEqual(raised.exception.code, 0)
def test_revoker_entrypoint_denies_when_identity_environment_is_half_provisioned(
self,
) -> None:
# The no-op above is reachable ONLY when identity is TOTALLY absent. A
# half-provisioned environment is a machinery-present failure and must still
# fail closed. main() already pins this; the entrypoint did not, and the
# entrypoint is what the runtime extension actually spawns.
half_provisioned = (
{"MOSAIC_LEASE_BROKER_SOCKET": "/run/test/broker.sock"},
{"MOSAIC_LEASE_SESSION_ID": "d" * 64},
)
for environment in half_provisioned:
with self.subTest(environment=environment), patch.object(
sys,
"argv",
[
str(TOOLS_DIR / "revoke-lease.py"),
"--runtime",
"claude",
"--reason",
"pre-compact",
],
), patch.dict(os.environ, environment, clear=True), redirect_stderr(
io.StringIO()
), self.assertRaises(SystemExit) as raised:
runpy.run_path(str(TOOLS_DIR / "revoke-lease.py"), run_name="__main__")
self.assertEqual(raised.exception.code, 2)
self.assertEqual(raised.exception.code, 2)
def test_gate_entrypoint_denies_when_identity_environment_is_absent(self) -> None:
# Deliberately NOT changed alongside its revoker twin above. The asymmetry is
# intentional: the gate's deny-on-absent is the authorization path and is
# load-bearing, so absent identity must fail closed here. The revoker's rc=2
# was inert in the same case (no session id means no broker call is possible),
# which is why only the revoker moved under D29. Do not "restore symmetry".
class Stdin:
buffer = io.BytesIO(b'{"tool_name":"Bash"}')
@@ -742,11 +703,8 @@ class LeaseRevocationTest(unittest.TestCase):
"MOSAIC_RUNTIME_GENERATION": "1",
}
malformed_session = {**good, "MOSAIC_LEASE_SESSION_ID": "not-a-session"}
# D29 exemption: the `({}, ...)` case was removed from this list. An empty
# environment is absence-of-lease, not an identity/reply/transport failure, and
# its correct result is no-op success (pinned in revoke_noop_unittest.py). The
# five cases below are all machinery-present failures and stay fail-closed.
cases = [
({}, lambda *_args: {"ok": True, "state": "UNVERIFIED"}),
(malformed_session, lambda *_args: {"ok": True, "state": "UNVERIFIED"}),
(good, lambda *_args: {"ok": False, "state": "UNVERIFIED"}),
(good, lambda *_args: {"ok": True, "state": "VERIFIED"}),