fix: placement verification and sidechat policy enforcement

- dm.py: placement-aware verify (verify_placement), checked_uuid logging, placement_mismatch events
- main-chat-watchdog.py: P0 alerts on placement_mismatch
- box-ctl.py: notify routes to sidechat, box policy command
- jobs: ops-audit and pipe-demo use sidechats
- response-harvester.py: chain deduplication

Session: sidechat/ops-restore
This commit is contained in:
operator-main
2026-10-04 18:29:13 +00:00
parent ae1bf17de5
commit 550e30901b
14 changed files with 1122 additions and 47 deletions
+215 -21
View File
@@ -17,14 +17,18 @@ after 3 attempts. `send --raw` transmits verbatim (for pre-signed
messages): no tagging, no truncation — the signed payload must survive
byte-identical.
Policy (2026-10-04): sidechat-first. Main-chat sends are refused unless
--allow-main-chat is passed explicitly. Never saturate main threads by
default; use a sidechat target instead.
Usage:
dm.py send --agent opm --to 646 --target main "message"
dm.py send --agent opm --target main --raw "$(dm-sign.sh --from operator-main 'hi')"
dm.py verify-sig --agent opm --target main # scan recent reads for signed DMs
dm.py send --agent opm --to 646 --target "646 tasks" "message"
dm.py send --agent opm --target main --allow-main-chat --raw "$(dm-sign.sh --from operator-main 'hi')"
dm.py verify-sig --agent opm --target "646 tasks" # scan recent reads for signed DMs
dm.py verify-sig "$(dm-sign.sh --from operator-main 'hi')" # verify text directly
dm.py read --agent 646 --target main [n]
dm.py read --agent 646 --target "646 tasks" [n]
dm.py log [--n 20]
dm.py thread --from opm --to 646 --target main "message"
dm.py thread --from opm --to 646 --target "646 tasks" "message"
"""
import argparse
import os
@@ -203,12 +207,16 @@ LOG_FILE = "/home/super/Projects/NetVM/dm-log.jsonl"
SIDCHAT_ALIASES = {
"646-opm-work": "d410b9ad-f667-465f-a103-43fabc0f69fe",
"646 tasks": "1e75a740-d08f-443d-a0f9-793db196e24f",
"heartbeat": "1e75a740-d08f-443d-a0f9-793db196e24f",
"heartbeat": "0077e918-9ac8-40ba-b0ad-65f9f78037c4", # dedicated heartbeat sidechat (was colliding with 646 tasks)
}
def resolve_sidechat_target(target):
"""Resolve target alias or name to UUID dynamically from job-sidechats.json."""
if not target or target == "main":
if not target or not str(target).strip():
raise ValueError(
"DM target must not be empty: specify a sidechat name/UUID, "
"or main (main requires --allow-main-chat)")
if target == "main":
return target
if re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", target.lower()):
return target.lower()
@@ -229,6 +237,32 @@ def resolve_sidechat_target(target):
return target
def resolve_sidechat_source(target):
"""Return where a target resolved from, for placement auditing.
Values: main / direct_uuid / static_alias / dynamic_mapping / passthrough.
Mirrors resolve_sidechat_target() logic without changing its signature.
"""
if not target or not str(target).strip():
return "invalid"
if target == "main":
return "main"
if re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", target.lower()):
return "direct_uuid"
if target in SIDCHAT_ALIASES:
return "static_alias"
sc_file = "/home/super/Projects/NetVM/job-sidechats.json"
if os.path.exists(sc_file):
try:
with open(sc_file, "r", encoding="utf-8") as f:
sc_data = json.load(f)
if sc_data.get(target):
return "dynamic_mapping"
except Exception:
pass
return "passthrough"
def log_event(event):
"""Append to JSONL log."""
event["ts"] = datetime.now(timezone.utc).isoformat()
@@ -250,8 +284,81 @@ def run_full(cmd, timeout=60):
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
return result.returncode, result.stdout.strip(), result.stderr.strip()
def verify_placement(recipient, msg_id, target, thread_uuid):
"""Authoritative post-send placement verification (2026-10-04).
The send/verify loop only proves the message exists in whichever chat
the browser was parked on at read time. A silent navigation drift (the
SPA restoring main chat after a thread-URL navigation, a stale DOM read)
used to produce verified:true for messages that actually landed in main.
This re-navigates to the target by direct URL, asserts the post-nav URL
matches the target, then reads the chat back and asserts the message ID
is present IN THAT CHAT. Returns (ok, detail). Read-only retries only --
never resends: a misplaced send must fail loudly, not be duplicated.
"""
detail = {"target": target, "thread_uuid": thread_uuid}
try:
if target == "main":
_rc, _out, _err = run_full(
f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat main")
if _rc != 0:
detail.update({"reason": "nav_failed", "rc": _rc, "err": _err[:200]})
return False, detail
time.sleep(2)
_u_rc, _u_out, _u_err = run_full(
f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} url")
detail["actual_url"] = _u_out[:200]
if "/thread/" in _u_out:
detail["reason"] = "url_mismatch"
detail["expected"] = "main chat (no /thread/ in URL)"
return False, detail
else:
expected_uuid = (thread_uuid or "").lower()
detail["expected_uuid"] = expected_uuid or None
if not expected_uuid:
detail["reason"] = "no_thread_uuid"
return False, detail
# Direct-URL navigation: sidechat use <uuid> sets
# window.location.href to /thread/<uuid> and confirms it.
_rc, _out, _err = run_full(
f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat use {expected_uuid}")
if _rc != 0 or "NOTFOUND" in _out or expected_uuid not in _out.lower():
detail.update({"reason": "nav_failed", "rc": _rc,
"out": _out[:200], "err": _err[:200]})
return False, detail
time.sleep(2)
# Independent URL assertion: the nav command's own confirm read
# the same window.location.href, so sample it again here.
_u_rc, _u_out, _u_err = run_full(
f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} url")
detail["actual_url"] = _u_out[:200]
if expected_uuid not in _u_out.lower():
detail["reason"] = "url_mismatch"
return False, detail
# Read-back with read-only retries: the SPA may still be rendering
# the thread after navigation. A miss here must NOT trigger a resend.
for _r in range(3):
try:
check_msgs = run(
f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} messages 5 200")
except Exception as e:
detail["read_error"] = str(e)[:100]
time.sleep(2)
continue
if msg_id in check_msgs:
return True, detail
time.sleep(2)
detail["reason"] = "msg_not_in_target_chat"
return False, detail
except Exception as e:
detail["reason"] = "exception"
detail["error"] = str(e)[:200]
return False, detail
def dm_send(agent, target, message, verify=True, raw=False,
to_agent=None, tags=None, nudge_meta=None):
to_agent=None, tags=None, nudge_meta=None,
allow_main_chat=False):
"""Send a DM. raw=True sends verbatim (for pre-signed messages from
dm-sign.sh, which already carry [from:X] [id:Y]): no tagging, no
truncation. Non-raw messages are tagged [from:<agent>] [id:<uuid8>]."""
@@ -275,9 +382,39 @@ def dm_send(agent, target, message, verify=True, raw=False,
# Single unified attribution format (matches verify-sig's regex).
tagged = f"[from:{agent}] [id:{msg_id}] {message}"
# Empty target is an error, not a silent main redirect (2026-10-04).
# Only explicit --allow-main-chat reaches main.
if not target or not str(target).strip():
print(f"ERROR: DM target must not be empty (agent={agent} to={recipient}). "
f"Specify a sidechat name/UUID, or main with --allow-main-chat.",
file=sys.stderr)
sys.exit(2)
# Sidechat-first policy (2026-10-04): refuse main-chat sends unless the
# caller explicitly opted in. Main threads must never be saturated by
# default; use a sidechat target instead.
if target == "main" and not allow_main_chat:
log_event({"type": "main_chat_blocked", "id": msg_id, "agent": agent,
"to": recipient, "target": target})
print(f"ERROR: Refusing main-chat send by policy (agent={agent} to={recipient}). "
f"Use a sidechat target, or pass --allow-main-chat for explicit main-chat sends.",
file=sys.stderr)
sys.exit(2)
# Canonical follow-up tags: trailing [bracket] tokens are metadata,
# stripped from the delivered text, recorded on the log events.
tags = tags or {}
# Sidechat-first policy audit marker (2026-10-04): record explicit
# main-chat opt-in so the main-chat watchdog can distinguish
# authorized main sends from policy violations.
if allow_main_chat and target == "main":
tags["allow_main_chat"] = True
# Extract job_id from [JOB <id>] marker for followup correlation.
# This lets the harvester resolve followups by job_id when a
# [RESULT <job_id>] reply arrives, even if thread_uuid is null.
if "job_id" not in tags:
_jm = re.search(r'\[JOB\s+([A-Za-z0-9_-]+)\]', message)
if _jm:
tags["job_id"] = _jm.group(1)
if not raw:
message, text_tags = extract_trailing_tags(message)
if text_tags:
@@ -301,7 +438,8 @@ def dm_send(agent, target, message, verify=True, raw=False,
# Resolve well-known aliases or dynamic thread mappings to UUIDs.
nav_target = resolve_sidechat_target(target)
if nav_target != target:
log_event({"type": "alias_resolved", "id": msg_id, "target": target, "thread_uuid": nav_target})
log_event({"type": "alias_resolved", "id": msg_id, "target": target, "thread_uuid": nav_target,
"source": resolve_sidechat_source(target)})
if target == "main":
_rc, _out, _err = run_full(f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat main")
else:
@@ -333,7 +471,8 @@ def dm_send(agent, target, message, verify=True, raw=False,
sys.exit(1)
is_new_sidechat = True
log_event({"type": "nav_ok", "id": msg_id, "agent": agent, "to": recipient,
"target": target, "status": "sidechat_created_pending_uuid"})
"target": target, "status": "sidechat_created_pending_uuid",
"browser_url": thread_url})
else:
m = re.search(r"/thread/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})", _out, re.I)
if m:
@@ -358,7 +497,8 @@ def dm_send(agent, target, message, verify=True, raw=False,
file=sys.stderr)
sys.exit(1)
log_event({"type": "nav_ok", "id": msg_id, "agent": agent, "to": recipient,
"target": target, "thread_uuid": thread_uuid})
"target": target, "thread_uuid": thread_uuid,
"browser_url": thread_url})
time.sleep(2)
@@ -372,6 +512,7 @@ def dm_send(agent, target, message, verify=True, raw=False,
# so we verify by reading the recipient's chat for our message ID.
max_retries = 3
delivered = False
verified_attempt = None
for attempt in range(max_retries):
send_out = run(f'{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} send "{safe}"',
priority="high")
@@ -408,8 +549,28 @@ def dm_send(agent, target, message, verify=True, raw=False,
check_msgs = run(f'{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} messages 5 200')
if msg_id in check_msgs:
delivered = True
log_event({"type": "verified", "id": msg_id, "agent": agent, "to": recipient, "target": target,
"thread_uuid": thread_uuid, "attempt": attempt + 1})
verified_attempt = attempt + 1
# Capture the actual browser URL at verify time. `messages`
# reads whichever chat the browser is currently parked on; if
# that isn't the thread we navigated to, the send went to the
# wrong chat (placement-blindness, 2026-10-04). This is only
# an early signal -- the authoritative placement check runs
# after this loop, and verified:true is only logged there.
_v_rc, _v_out, _v_err = run_full(f'{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} url')
_v_m = re.search(r"/thread/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})", _v_out, re.I)
checked_uuid = _v_m.group(1).lower() if _v_m else None
if target == "main":
if checked_uuid:
# Browser was parked in a sidechat thread while
# sending to main: the message likely went to the
# sidechat, not main.
log_event({"type": "placement_mismatch", "id": msg_id, "agent": agent, "to": recipient,
"target": target, "expected": "main", "actual_uuid": checked_uuid,
"attempt": attempt + 1})
elif checked_uuid and thread_uuid and checked_uuid != thread_uuid:
log_event({"type": "placement_mismatch", "id": msg_id, "agent": agent, "to": recipient,
"target": target, "expected_uuid": thread_uuid, "actual_uuid": checked_uuid,
"attempt": attempt + 1})
break
else:
log_event({"type": "retry", "id": msg_id, "agent": agent, "to": recipient, "attempt": attempt + 1})
@@ -458,6 +619,29 @@ def dm_send(agent, target, message, verify=True, raw=False,
if delivered and thread_uuid and "thread" not in tags:
tags["thread"] = thread_uuid
if delivered:
# Authoritative placement verification (2026-10-04): the loop above
# only proved the message exists in whichever chat the browser was
# parked on. Re-navigate to the target by direct URL, assert the
# post-nav URL, and read the message back IN THAT CHAT before
# logging verified:true. A failure here means the message landed in
# the wrong chat: fail loudly (do NOT resend -- that would
# duplicate the misplaced message).
placed, pdetail = verify_placement(recipient, msg_id, target, thread_uuid)
if not placed:
log_event({"type": "placement_failed", "id": msg_id, "agent": agent,
"to": recipient, "loop_attempt": verified_attempt, **pdetail})
log_event({"type": "failed", "id": msg_id, "agent": agent, "to": recipient,
"target": target, "verified": False, "reason": "placement_failed"})
print(f"DM {msg_id} from {agent} to {recipient}/{target}: FAILED "
f"(placement check failed: {pdetail.get('reason')}; "
f"expected={pdetail.get('expected_uuid') or pdetail.get('expected')}; "
f"actual_url={pdetail.get('actual_url')})", file=sys.stderr)
sys.exit(1)
log_event({"type": "verified", "id": msg_id, "agent": agent, "to": recipient,
"target": target, "thread_uuid": thread_uuid,
"placement": "confirmed", "attempt": verified_attempt})
# Note: Leave recipient browser parked in the sidechat thread to preserve Main Chat DOM
log_event({"type": "send_done", "id": msg_id, "agent": agent, "to": recipient, "target": target})
@@ -646,20 +830,24 @@ def dm_log(n=20):
e = json.loads(line)
print(f"{e['ts'][:19]} {e.get('type','?'):12} {e.get('id','-'):8} {e.get('agent','-')}/{e.get('target','-')}")
def dm_thread(from_agent, to_agent, target, message):
def dm_thread(from_agent, to_agent, target, message, allow_main_chat=False):
"""Thread from one agent to another. Attribution is applied exactly once,
in the unified [from:X] [id:Y] format, by dm_send."""
return dm_send(from_agent, target, message, to_agent=to_agent)
return dm_send(from_agent, target, message, to_agent=to_agent,
allow_main_chat=allow_main_chat)
def main():
p = argparse.ArgumentParser(description="DM: Headless Direct Messages (tagged [from:X] [id:Y], logged; delivery confirmed by recipient read-back)")
p = argparse.ArgumentParser(description="DM: Headless Direct Messages (tagged [from:X] [id:Y], logged; delivery confirmed by recipient read-back). Sidechat-first policy: --target main requires --allow-main-chat.")
sub = p.add_subparsers(dest='cmd', required=True)
ps = sub.add_parser('send', help='Send a DM (tagged; delivery confirmed by recipient read-back, up to 3 attempts)')
ps.add_argument('--agent', required=True, choices=VALID_SENDERS)
ps.add_argument('--to', required=False, choices=VALID_RECIPIENTS, default=None,
help='Recipient operator (for cross-operator DMs). Uses recipient\'s browser/chat.')
ps.add_argument('--target', required=True)
ps.add_argument('--target', required=True,
help='Conversation: sidechat name or thread UUID. Sidechat-first policy: --target main requires --allow-main-chat.')
ps.add_argument('--allow-main-chat', action='store_true',
help='Explicit opt-in for main-chat sends (refused by default per sidechat-first policy)')
ps.add_argument('--no-verify', action='store_true',
help='Accepted for compatibility but ignored: recipient-side read-back verification always runs.')
ps.add_argument('--raw', action='store_true', help='Send verbatim: no tagging, no truncation (for pre-signed messages from dm-sign.sh)')
@@ -698,7 +886,8 @@ def main():
sys.exit(2)
return dm_send(a.agent, a.target, a.message, verify=not a.no_verify,
raw=a.raw, to_agent=a.to, tags=tags,
nudge_meta=nudge_meta)
nudge_meta=nudge_meta,
allow_main_chat=a.allow_main_chat)
ps.set_defaults(func=_send_with_tags)
psel = sub.add_parser('select', help='Select active conversation')
@@ -726,9 +915,13 @@ def main():
pt = sub.add_parser('thread', help='Thread from one agent to another')
pt.add_argument('--from', dest='from_agent', required=True, choices=VALID_SENDERS)
pt.add_argument('--to', dest='to_agent', required=True, choices=VALID_RECIPIENTS)
pt.add_argument('--target', required=True)
pt.add_argument('--target', required=True,
help='Conversation: sidechat name or thread UUID. Sidechat-first policy: main requires --allow-main-chat.')
pt.add_argument('--allow-main-chat', action='store_true',
help='Explicit opt-in for main-chat sends (refused by default per sidechat-first policy)')
pt.add_argument('message')
pt.set_defaults(func=lambda a: dm_thread(a.from_agent, a.to_agent, a.target, a.message))
pt.set_defaults(func=lambda a: dm_thread(a.from_agent, a.to_agent, a.target, a.message,
allow_main_chat=a.allow_main_chat))
args = p.parse_args()
args.func(args)
@@ -771,7 +964,7 @@ def _register_followup(msg_id, agent, recipient, target, tags):
if tags.get("reply:expected"):
tags_obj["reply:expected"] = True
for key in ("reply:timeout", "reply:nudges", "reply:escalate",
"route", "thread"):
"route", "thread", "job_id"):
if key in tags:
tags_obj[key] = tags[key]
@@ -824,6 +1017,7 @@ def _register_followup(msg_id, agent, recipient, target, tags):
"recipient": recipient,
"target": target,
"thread_uuid": tags.get("thread"),
"job_id": tags.get("job_id"),
"route": tags.get("route"),
"sent_at": now_dt.isoformat(),
"deadline": deadline_dt.isoformat(),