1362 lines
49 KiB
Python
Executable File
1362 lines
49 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Run a PII-safe live audit of every read-only Chat Shortcut.
|
||
|
||
The audit exercises the real Shortcut entry point and, when DWS_DUMP_RAW is
|
||
enabled by the locally built binary, captures the lower MCP text response from
|
||
the same invocation. Raw business responses are kept in memory only. The
|
||
written JSON/Markdown reports contain shapes, counts, tool names, and redacted
|
||
error facts, but no message text, user names, IDs, or invite/download URLs.
|
||
|
||
This is deliberately a live-account audit, not a mock/Help contract check.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import signal
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||
sys.path.insert(0, str(SCRIPT_DIR))
|
||
|
||
from shortcut_real_result import count_projection_items, payload_indicates_error # noqa: E402
|
||
|
||
|
||
READ_SHORTCUTS = (
|
||
"+at-me",
|
||
"+bot-find",
|
||
"+bot-search",
|
||
"+category-list",
|
||
"+category-list-conversations",
|
||
"+chat-bots",
|
||
"+chat-get-by-id",
|
||
"+chat-invite-url",
|
||
"+chat-list-all",
|
||
"+chat-list-join-requests",
|
||
"+chat-list-mine",
|
||
"+chat-members-list",
|
||
"+chat-members-get",
|
||
"+chat-messages",
|
||
"+chat-role-list",
|
||
"+chat-role-query-user",
|
||
"+chat-search",
|
||
"+conversation-info",
|
||
"+conversation-list",
|
||
"+conversation-list-top",
|
||
"+group-members",
|
||
"+messages-list",
|
||
"+messages-list-direct",
|
||
"+messages-list-pin",
|
||
"+messages-list-unread-conversations",
|
||
"+messages-mget",
|
||
"+messages-query-send-status",
|
||
"+messages-read-status",
|
||
"+messages-resource-download",
|
||
"+messages-resource-url",
|
||
"+my-groups",
|
||
"+search-msg",
|
||
"+thread-replies",
|
||
"+unread-chats",
|
||
)
|
||
|
||
LIST_PATHS_BY_TOOL = {
|
||
"search_at_me_message": (
|
||
("result", "conversationMessagesList", "*", "messages"),
|
||
),
|
||
"search_bots": (("result", "bots"), ("result", "list")),
|
||
"search_my_robots": (("result", "robots"), ("result", "list")),
|
||
"list_user_define_conv_categories": (
|
||
("result", "categories"),
|
||
("result", "categoryList"),
|
||
),
|
||
"list_conversations_by_category": (
|
||
("result", "conversations"),
|
||
("result", "conversationList"),
|
||
),
|
||
"list_group_bots": (("result", "bots"), ("result", "robots")),
|
||
"list_my_groups_pagination": (("result", "groups"),),
|
||
"list_owned_or_admin_groups": (("result", "groups"),),
|
||
"list_group_member_by_ids": (
|
||
("result", "members"),
|
||
("result", "list"),
|
||
),
|
||
"list_custom_group_roles": (("result", "roles"), ("result", "list")),
|
||
"list_all_conversations": (
|
||
("result", "conversations"),
|
||
("result", "conversationList"),
|
||
),
|
||
"list_top_conversations": (
|
||
("result", "conversations"),
|
||
("result", "conversationList"),
|
||
),
|
||
"get_group_members": (("result", "list"), ("result", "members")),
|
||
"list_conversation_message_v2": (
|
||
("result", "messages"),
|
||
("result", "messageList"),
|
||
),
|
||
"list_individual_chat_message": (
|
||
("result", "messages"),
|
||
("result", "messageList"),
|
||
),
|
||
"list_pin_messages": (
|
||
("result", "messages"),
|
||
("result", "pinMessages"),
|
||
("result", "list"),
|
||
),
|
||
"unread_message_conversation_list": (
|
||
("result", "conversations"),
|
||
("result", "conversationList"),
|
||
),
|
||
"list_messages_by_ids": (("result", "messages"), ("result", "list")),
|
||
"search_messages_by_keyword": (
|
||
("result", "messages"),
|
||
("result", "messageList"),
|
||
("result", "list"),
|
||
),
|
||
"list_topic_replies": (
|
||
("result", "messages"),
|
||
("result", "replies"),
|
||
("result", "list"),
|
||
),
|
||
}
|
||
|
||
@dataclass
|
||
class Capture:
|
||
command: str
|
||
argv: list[str]
|
||
exit_code: int | None
|
||
stdout: str
|
||
stderr: str
|
||
raw_calls: list[tuple[str, str, Any]]
|
||
duration_ms: int
|
||
timed_out: bool = False
|
||
|
||
def upper(self) -> Any:
|
||
try:
|
||
return json.loads(self.stdout)
|
||
except (json.JSONDecodeError, TypeError):
|
||
return None
|
||
|
||
|
||
def parse_raw_calls(stderr: str) -> tuple[list[tuple[str, str, Any]], str]:
|
||
raw_calls: list[tuple[str, str, Any]] = []
|
||
clean_lines: list[str] = []
|
||
for line in (stderr or "").splitlines():
|
||
if not line.startswith("DWSRAW\t"):
|
||
clean_lines.append(line)
|
||
continue
|
||
fields = line.split("\t", 3)
|
||
if len(fields) != 4:
|
||
continue
|
||
try:
|
||
raw_calls.append((fields[1], fields[2], json.loads(fields[3])))
|
||
except json.JSONDecodeError:
|
||
raw_calls.append((fields[1], fields[2], fields[3]))
|
||
return raw_calls, "\n".join(clean_lines)
|
||
|
||
|
||
def run_capture(binary: Path, command: str, args: list[str], timeout: int) -> Capture:
|
||
argv = [str(binary), "chat", command, *args, "--format", "json"]
|
||
env = os.environ.copy()
|
||
env["DWS_DUMP_RAW"] = "1"
|
||
started = time.monotonic()
|
||
proc = subprocess.Popen(
|
||
argv,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
text=True,
|
||
env=env,
|
||
start_new_session=True,
|
||
)
|
||
try:
|
||
stdout, stderr = proc.communicate(timeout=timeout)
|
||
raw_calls, clean_stderr = parse_raw_calls(stderr)
|
||
return Capture(
|
||
command=command,
|
||
argv=argv,
|
||
exit_code=proc.returncode,
|
||
stdout=stdout,
|
||
stderr=clean_stderr,
|
||
raw_calls=raw_calls,
|
||
duration_ms=round((time.monotonic() - started) * 1000),
|
||
)
|
||
except subprocess.TimeoutExpired as exc:
|
||
# dws may have transport children which inherit its output pipes.
|
||
# Killing only the immediate process leaves those descendants alive and
|
||
# communicate() can block forever despite the advertised timeout.
|
||
os.killpg(proc.pid, signal.SIGKILL)
|
||
stdout, stderr = proc.communicate()
|
||
raw_calls, clean_stderr = parse_raw_calls(stderr)
|
||
return Capture(
|
||
command=command,
|
||
argv=argv,
|
||
exit_code=None,
|
||
stdout=stdout,
|
||
stderr=clean_stderr,
|
||
raw_calls=raw_calls,
|
||
duration_ms=round((time.monotonic() - started) * 1000),
|
||
timed_out=True,
|
||
)
|
||
|
||
|
||
def at_path(value: Any, path: tuple[str, ...]) -> list[Any] | None:
|
||
current = value
|
||
for index, part in enumerate(path):
|
||
if part == "*":
|
||
if not isinstance(current, list):
|
||
return None
|
||
remainder = path[index + 1 :]
|
||
combined: list[Any] = []
|
||
for item in current:
|
||
found = at_path(item, remainder)
|
||
if isinstance(found, list):
|
||
combined.extend(found)
|
||
return combined
|
||
if not isinstance(current, dict) or part not in current:
|
||
return None
|
||
current = current[part]
|
||
return current if isinstance(current, list) else None
|
||
|
||
|
||
def lower_count(tool: str, raw: Any) -> int | None:
|
||
for path in LIST_PATHS_BY_TOOL.get(tool, ()):
|
||
found = at_path(raw, path)
|
||
if found is not None:
|
||
return len(found)
|
||
return None
|
||
|
||
|
||
def distinct_lower_message_count(
|
||
raw_calls: list[tuple[str, str, Any]], tool: str
|
||
) -> int | None:
|
||
"""Count unique messages across all pages without retaining their IDs.
|
||
|
||
Time-boundary pagination may repeat the boundary message on the following
|
||
page. Summing page lengths would therefore report a false projection
|
||
mismatch for an otherwise correct de-duplicating Shortcut.
|
||
"""
|
||
metrics = distinct_lower_message_metrics(raw_calls, tool)
|
||
return metrics[0] if metrics is not None else None
|
||
|
||
|
||
def distinct_lower_message_metrics(
|
||
raw_calls: list[tuple[str, str, Any]], tool: str
|
||
) -> tuple[int, int, int] | None:
|
||
"""Aggregate count/reaction/thread facts over one stable message-ID set."""
|
||
seen: set[str] = set()
|
||
reaction_messages: set[str] = set()
|
||
thread_messages: set[str] = set()
|
||
anonymous = 0
|
||
found_collection = False
|
||
identity_keys = ("openMessageId", "openMsgId", "messageId", "msgId", "id")
|
||
for _, call_tool, raw in raw_calls:
|
||
if call_tool != tool:
|
||
continue
|
||
rows: list[Any] | None = None
|
||
for path in LIST_PATHS_BY_TOOL.get(tool, ()):
|
||
rows = at_path(raw, path)
|
||
if rows is not None:
|
||
break
|
||
if rows is None:
|
||
continue
|
||
found_collection = True
|
||
for row in rows:
|
||
identity = ""
|
||
if isinstance(row, dict):
|
||
for key in identity_keys:
|
||
value = row.get(key)
|
||
if value not in (None, ""):
|
||
identity = f"id:{value}"
|
||
break
|
||
if not identity:
|
||
identity = f"anonymous:{anonymous}"
|
||
anonymous += 1
|
||
seen.add(identity)
|
||
if reaction_message_count(row, upper=False) > 0:
|
||
reaction_messages.add(identity)
|
||
if thread_message_count(row, upper=False) > 0:
|
||
thread_messages.add(identity)
|
||
if not found_collection:
|
||
return None
|
||
return len(seen), len(reaction_messages), len(thread_messages)
|
||
|
||
|
||
def distinct_lower_collection_count(
|
||
raw_calls: list[tuple[str, str, Any]],
|
||
tool: str,
|
||
identity_keys: tuple[str, ...],
|
||
) -> int | None:
|
||
"""Count unique rows across every lower page for one tool."""
|
||
seen: set[str] = set()
|
||
anonymous = 0
|
||
found_collection = False
|
||
for _, call_tool, raw in raw_calls:
|
||
if call_tool != tool:
|
||
continue
|
||
rows: list[Any] | None = None
|
||
for path in LIST_PATHS_BY_TOOL.get(tool, ()):
|
||
rows = at_path(raw, path)
|
||
if rows is not None:
|
||
break
|
||
if rows is None:
|
||
continue
|
||
found_collection = True
|
||
for row in rows:
|
||
if not isinstance(row, dict):
|
||
anonymous += 1
|
||
continue
|
||
identity = ""
|
||
for key in identity_keys:
|
||
value = row.get(key)
|
||
if value not in (None, ""):
|
||
identity = str(value)
|
||
break
|
||
if identity:
|
||
seen.add(identity)
|
||
else:
|
||
anonymous += 1
|
||
if not found_collection:
|
||
return None
|
||
return len(seen) + anonymous
|
||
|
||
|
||
def response_is_error(value: Any) -> bool:
|
||
if not isinstance(value, dict):
|
||
return False
|
||
if value.get("success") is False:
|
||
return True
|
||
if str(value.get("status") or "").lower() == "error":
|
||
return True
|
||
for key in ("error", "errorCode", "error_code", "code"):
|
||
item = value.get(key)
|
||
if item not in (None, False, "", 0, "0", "ok", "success", "succeed"):
|
||
return True
|
||
return False
|
||
|
||
|
||
def error_facts(stderr: str) -> dict[str, Any]:
|
||
facts: dict[str, Any] = {}
|
||
try:
|
||
value = json.loads(stderr)
|
||
error = value.get("error", value) if isinstance(value, dict) else {}
|
||
if isinstance(error, dict):
|
||
for key in ("category", "code", "reason", "server_error_code", "server_key"):
|
||
if key in error:
|
||
facts[key] = error[key]
|
||
message = str(error.get("message") or "")
|
||
else:
|
||
message = stderr
|
||
except json.JSONDecodeError:
|
||
message = stderr
|
||
lowered = message.lower()
|
||
for marker, label in (
|
||
("permission", "permission"),
|
||
("not found", "not_found"),
|
||
("不存在", "not_found"),
|
||
("invalid", "invalid_argument"),
|
||
("必填", "missing_argument"),
|
||
("超时", "timeout"),
|
||
):
|
||
if marker in lowered:
|
||
facts["message_class"] = label
|
||
break
|
||
return facts
|
||
|
||
|
||
def shape(value: Any) -> dict[str, Any]:
|
||
if isinstance(value, dict):
|
||
return {"type": "object", "keys": sorted(value)}
|
||
if isinstance(value, list):
|
||
return {"type": "array", "length": len(value)}
|
||
if value is None:
|
||
return {"type": "none"}
|
||
return {"type": type(value).__name__}
|
||
|
||
|
||
def reaction_message_count(value: Any, *, upper: bool) -> int:
|
||
"""Count reaction-bearing message objects without retaining business values."""
|
||
count = 0
|
||
if isinstance(value, dict):
|
||
keys = ("reactions", "emotionReplyList", "reactionList") if upper else (
|
||
"emotionReplyList",
|
||
"reactionList",
|
||
"reactions",
|
||
)
|
||
if any(value.get(key) not in (None, [], {}) for key in keys):
|
||
count += 1
|
||
for item in value.values():
|
||
count += reaction_message_count(item, upper=upper)
|
||
elif isinstance(value, list):
|
||
for item in value:
|
||
count += reaction_message_count(item, upper=upper)
|
||
return count
|
||
|
||
|
||
def primary_projection_for_message_metrics(value: Any) -> Any:
|
||
"""Select one canonical projected collection for message-level metrics.
|
||
|
||
Some stable Shortcut payloads publish compatibility aliases such as
|
||
``messages`` and ``items`` with the same rows. Walking the entire envelope
|
||
would count reactions and thread IDs once per alias and report a false
|
||
projection mismatch even though the lower and upper message sets agree.
|
||
"""
|
||
if not isinstance(value, dict):
|
||
return value
|
||
for key in ("messages", "replies", "results", "items"):
|
||
items = value.get(key)
|
||
if isinstance(items, list):
|
||
return items
|
||
return value
|
||
|
||
|
||
def thread_message_count(value: Any, *, upper: bool) -> int:
|
||
keys = {"threadId"} if upper else {
|
||
"openConvThreadId",
|
||
"openConversationThreadId",
|
||
"threadId",
|
||
"topicId",
|
||
}
|
||
count = 0
|
||
if isinstance(value, dict):
|
||
if any(value.get(key) not in (None, "") for key in keys):
|
||
count += 1
|
||
for item in value.values():
|
||
count += thread_message_count(item, upper=upper)
|
||
elif isinstance(value, list):
|
||
for item in value:
|
||
count += thread_message_count(item, upper=upper)
|
||
return count
|
||
|
||
|
||
def pagination_meta(value: Any) -> dict[str, Any]:
|
||
"""Extract only completeness facts; never retain cursor values."""
|
||
if isinstance(value, dict):
|
||
has_more = None
|
||
for key in ("hasMore", "has_more"):
|
||
if key in value and isinstance(value[key], bool):
|
||
has_more = value[key]
|
||
break
|
||
cursor_present = any(
|
||
value.get(key) not in (None, "", 0, "0")
|
||
for key in ("nextCursor", "next_cursor", "nextToken", "next_token", "pageToken", "page_token")
|
||
)
|
||
next_page = value.get("nextPage")
|
||
resume_present = cursor_present or (
|
||
isinstance(next_page, dict)
|
||
and next_page.get("time") not in (None, "")
|
||
)
|
||
if has_more is not None or resume_present:
|
||
return {
|
||
"has_more": has_more,
|
||
"next_cursor_present": cursor_present,
|
||
"resume_present": resume_present,
|
||
}
|
||
for item in value.values():
|
||
found = pagination_meta(item)
|
||
if found:
|
||
return found
|
||
elif isinstance(value, list):
|
||
for item in value:
|
||
found = pagination_meta(item)
|
||
if found:
|
||
return found
|
||
return {}
|
||
|
||
|
||
def summarize_capture(capture: Capture) -> dict[str, Any]:
|
||
upper = capture.upper()
|
||
raw = capture.raw_calls[-1][2] if capture.raw_calls else None
|
||
tool = capture.raw_calls[-1][1] if capture.raw_calls else None
|
||
upper_count = count_projection_items(capture.stdout)
|
||
expected_lower_count = lower_count(tool, raw) if tool else None
|
||
pagination_raw = raw
|
||
lower_message_projection = raw
|
||
lower_message_metrics: tuple[int, int, int] | None = None
|
||
if capture.command == "+thread-replies" and tool == "list_topic_replies":
|
||
lower_message_metrics = distinct_lower_message_metrics(
|
||
capture.raw_calls, "list_topic_replies"
|
||
)
|
||
expected_lower_count = lower_message_metrics[0] if lower_message_metrics else None
|
||
elif capture.command == "+at-me" and tool == "search_at_me_message":
|
||
lower_message_metrics = distinct_lower_message_metrics(
|
||
capture.raw_calls, "search_at_me_message"
|
||
)
|
||
expected_lower_count = lower_message_metrics[0] if lower_message_metrics else None
|
||
elif (
|
||
capture.command == "+messages-list-direct"
|
||
and tool == "list_individual_chat_message"
|
||
):
|
||
lower_message_metrics = distinct_lower_message_metrics(
|
||
capture.raw_calls, "list_individual_chat_message"
|
||
)
|
||
expected_lower_count = lower_message_metrics[0] if lower_message_metrics else None
|
||
elif capture.command in {"+chat-list-all", "+my-groups"} and tool == (
|
||
"list_my_groups_pagination"
|
||
):
|
||
expected_lower_count = distinct_lower_collection_count(
|
||
capture.raw_calls,
|
||
"list_my_groups_pagination",
|
||
(
|
||
"openConversationId",
|
||
"openconversation_id",
|
||
"conversationId",
|
||
"openCid",
|
||
"cid",
|
||
"id",
|
||
),
|
||
)
|
||
if capture.command == "+chat-members-list":
|
||
counts = upper.get("counts", {}) if isinstance(upper, dict) else {}
|
||
upper_count = counts.get("total") if isinstance(counts, dict) else None
|
||
expected_lower_count = 0
|
||
for _, call_tool, call_raw in capture.raw_calls:
|
||
if call_tool in {"get_group_members", "list_group_bots"}:
|
||
item_count = lower_count(call_tool, call_raw)
|
||
if item_count is not None:
|
||
expected_lower_count += item_count
|
||
if call_tool == "get_group_members":
|
||
pagination_raw = call_raw
|
||
elif capture.command == "+messages-resource-download":
|
||
upper_count = (
|
||
1
|
||
if isinstance(upper, dict)
|
||
and isinstance(upper.get("sizeBytes"), (int, float))
|
||
and upper.get("sizeBytes") >= 0
|
||
and bool(upper.get("localPath"))
|
||
else 0
|
||
)
|
||
expected_lower_count = (
|
||
1
|
||
if first_string(raw, "resourceUrl", "downloadUrl")
|
||
else 0
|
||
)
|
||
exact_passthrough = bool(capture.raw_calls and upper == raw)
|
||
upper_message_projection = primary_projection_for_message_metrics(upper)
|
||
upper_reactions = reaction_message_count(upper_message_projection, upper=True)
|
||
upper_threads = thread_message_count(upper_message_projection, upper=True)
|
||
if lower_message_metrics is not None:
|
||
_, lower_reactions, lower_threads = lower_message_metrics
|
||
else:
|
||
lower_reactions = reaction_message_count(lower_message_projection, upper=False)
|
||
lower_threads = thread_message_count(lower_message_projection, upper=False)
|
||
upper_pagination = pagination_meta(upper)
|
||
lower_pagination = pagination_meta(pagination_raw)
|
||
|
||
upper_explicitly_incomplete = (
|
||
isinstance(upper, dict)
|
||
and upper.get("complete") is False
|
||
and (
|
||
upper.get("hasMore") is True
|
||
or bool(upper.get("failures"))
|
||
or bool(upper.get("truncatedByPageLimit"))
|
||
or bool(upper.get("truncatedByResultLimit"))
|
||
)
|
||
)
|
||
|
||
if capture.timed_out:
|
||
status = "timeout"
|
||
elif capture.exit_code != 0:
|
||
status = "backend_error" if capture.stderr else "cli_error"
|
||
elif payload_indicates_error(capture.stdout) or response_is_error(raw):
|
||
status = "error_envelope"
|
||
elif not capture.raw_calls:
|
||
status = "no_lower_capture"
|
||
elif upper_explicitly_incomplete:
|
||
status = "incomplete"
|
||
elif expected_lower_count == 0 and upper_count in (None, 0):
|
||
# An empty collection is useful evidence, but it is not a successful
|
||
# non-empty business-data E2E. This must precede exact-passthrough so a
|
||
# raw empty list cannot be promoted to pass merely because projection
|
||
# preserved it byte-for-byte.
|
||
status = "pass_empty"
|
||
elif expected_lower_count is not None and upper_count != expected_lower_count:
|
||
status = "projection_mismatch"
|
||
elif upper_count == 0:
|
||
# The upper projection is empty but the lower response shape does not
|
||
# expose a countable collection, so correctness cannot be proven.
|
||
status = "projection_unverified"
|
||
elif exact_passthrough:
|
||
status = "pass"
|
||
elif lower_reactions > 0 and upper_reactions != lower_reactions:
|
||
status = "projection_mismatch"
|
||
elif lower_threads > 0 and upper_threads != lower_threads:
|
||
status = "projection_mismatch"
|
||
elif lower_pagination.get("has_more") is True and (
|
||
upper_pagination.get("has_more") is not True
|
||
or not upper_pagination.get("resume_present")
|
||
):
|
||
status = "pagination_mismatch"
|
||
else:
|
||
status = "pass"
|
||
|
||
return {
|
||
"command": capture.command,
|
||
"status": status,
|
||
"exit_code": capture.exit_code,
|
||
"duration_ms": capture.duration_ms,
|
||
"upper": {
|
||
**shape(upper),
|
||
"count": upper_count,
|
||
"reaction_message_count": upper_reactions,
|
||
"thread_message_count": upper_threads,
|
||
"pagination": upper_pagination,
|
||
},
|
||
"lower": {
|
||
"call_count": len(capture.raw_calls),
|
||
"tools": [f"{server}/{name}" for server, name, _ in capture.raw_calls],
|
||
"shape": shape(raw),
|
||
"count": expected_lower_count,
|
||
"reaction_message_count": lower_reactions,
|
||
"thread_message_count": lower_threads,
|
||
"pagination": lower_pagination,
|
||
},
|
||
"exact_passthrough": exact_passthrough,
|
||
"error": error_facts(capture.stderr) if capture.stderr else {},
|
||
}
|
||
|
||
|
||
def blocked(command: str, requirement: str) -> dict[str, Any]:
|
||
return {
|
||
"command": command,
|
||
"status": "fixture_blocked",
|
||
"requirement": requirement,
|
||
}
|
||
|
||
|
||
def dig_values(value: Any, keys: set[str]) -> list[Any]:
|
||
found: list[Any] = []
|
||
if isinstance(value, dict):
|
||
for key, item in value.items():
|
||
if key in keys and item not in (None, "", [], {}):
|
||
found.append(item)
|
||
found.extend(dig_values(item, keys))
|
||
elif isinstance(value, list):
|
||
for item in value:
|
||
found.extend(dig_values(item, keys))
|
||
elif isinstance(value, str):
|
||
stripped = value.strip()
|
||
if stripped.startswith(("{", "[")):
|
||
try:
|
||
embedded = json.loads(stripped)
|
||
except json.JSONDecodeError:
|
||
embedded = None
|
||
if isinstance(embedded, (dict, list)):
|
||
found.extend(dig_values(embedded, keys))
|
||
return found
|
||
|
||
|
||
def first_string(value: Any, *keys: str) -> str:
|
||
for item in dig_values(value, set(keys)):
|
||
if isinstance(item, str) and item.strip():
|
||
return item.strip()
|
||
if isinstance(item, (int, float)) and not isinstance(item, bool):
|
||
return str(int(item))
|
||
return ""
|
||
|
||
|
||
def first_list(value: Any, *keys: str) -> list[Any]:
|
||
for item in dig_values(value, set(keys)):
|
||
if isinstance(item, list):
|
||
return item
|
||
return []
|
||
|
||
|
||
def safe_keyword(messages: list[Any]) -> str:
|
||
for message in messages:
|
||
if not isinstance(message, dict):
|
||
continue
|
||
text = message.get("text")
|
||
if not isinstance(text, str):
|
||
continue
|
||
for token in re.findall(r"[\u4e00-\u9fff]{2,6}|[A-Za-z0-9]{3,12}", text):
|
||
if token:
|
||
return token
|
||
return "工作"
|
||
|
||
|
||
def group_candidates(capture: Capture) -> list[dict[str, Any]]:
|
||
raw = capture.raw_calls[-1][2] if capture.raw_calls else {}
|
||
groups = first_list(raw, "groups")
|
||
valid = [item for item in groups if isinstance(item, dict)]
|
||
priority = {"OWNER": 0, "ADMIN": 1}
|
||
return sorted(valid, key=lambda item: priority.get(str(item.get("myRole")), 2))
|
||
|
||
|
||
def merge_group_candidates(
|
||
groups_all: Capture, groups_mine: Capture
|
||
) -> list[dict[str, Any]]:
|
||
all_groups = group_candidates(groups_all)
|
||
mine_groups = group_candidates(groups_mine)
|
||
ordered: list[dict[str, Any]] = []
|
||
seen: set[str] = set()
|
||
for item in mine_groups:
|
||
group_id = str(item.get("openConversationId") or "")
|
||
if not group_id or group_id in seen:
|
||
continue
|
||
# The owned/admin endpoint carries myRole and ownerOpenDingtalkId,
|
||
# which are needed to locate a message sent by the current user. Prefer
|
||
# that richer record; the all-groups page is only a fallback.
|
||
ordered.append(item)
|
||
seen.add(group_id)
|
||
for item in all_groups:
|
||
group_id = str(item.get("openConversationId") or "")
|
||
if group_id and group_id not in seen:
|
||
ordered.append(item)
|
||
seen.add(group_id)
|
||
return ordered
|
||
|
||
|
||
def live_audit(
|
||
binary: Path, timeout: int, max_group_probes: int, max_member_probes: int
|
||
) -> list[dict[str, Any]]:
|
||
results: dict[str, dict[str, Any]] = {}
|
||
captures: dict[str, Capture] = {}
|
||
group_override = os.environ.get("DWS_AUDIT_GROUP_ID", "").strip()
|
||
numeric_group_override = os.environ.get("DWS_AUDIT_NUMERIC_GROUP_ID", "").strip()
|
||
open_task_override = os.environ.get("DWS_AUDIT_OPEN_TASK_ID", "").strip()
|
||
topic_group_override = os.environ.get("DWS_AUDIT_TOPIC_GROUP_ID", "").strip()
|
||
topic_id_override = os.environ.get("DWS_AUDIT_TOPIC_ID", "").strip()
|
||
topic_page_size = os.environ.get("DWS_AUDIT_TOPIC_PAGE_SIZE", "").strip()
|
||
media_group_override = os.environ.get("DWS_AUDIT_MEDIA_GROUP_ID", "").strip()
|
||
media_message_override = os.environ.get("DWS_AUDIT_MEDIA_MESSAGE_ID", "").strip()
|
||
media_resource_override = os.environ.get("DWS_AUDIT_MEDIA_RESOURCE_ID", "").strip()
|
||
|
||
def run(command: str, *args: str, retries: int = 0) -> Capture:
|
||
capture = run_capture(binary, command, list(args), timeout)
|
||
for _ in range(retries):
|
||
if summarize_capture(capture)["status"] not in {
|
||
"backend_error",
|
||
"cli_error",
|
||
"error_envelope",
|
||
"timeout",
|
||
"no_lower_capture",
|
||
}:
|
||
break
|
||
capture = run_capture(binary, command, list(args), timeout)
|
||
captures[command] = capture
|
||
results[command] = summarize_capture(capture)
|
||
return capture
|
||
|
||
category = run("+category-list")
|
||
groups_all = run(
|
||
"+chat-list-all", "--limit", "200", "--page-all", "--page-limit", "50"
|
||
)
|
||
groups_mine = run("+chat-list-mine", "--limit", "200")
|
||
run("+conversation-list", "--limit", "100")
|
||
run("+conversation-list-top", "--limit", "100")
|
||
run("+messages-list-unread-conversations", "--count", "100")
|
||
run("+unread-chats", "--count", "100")
|
||
run("+chat-list-join-requests", "--limit", "50")
|
||
run("+at-me", "--days", "30", "--page-all", "--page-limit", "50")
|
||
run("+bot-search", "--page", "1", "--size", "100")
|
||
run("+bot-find", "--query", "机器人", "--limit", "20")
|
||
run("+my-groups", "--page-all", "--page-limit", "50")
|
||
|
||
category_id = first_string(category.upper(), "categoryId", "category_id")
|
||
if category_id:
|
||
run("+category-list-conversations", "--category-id", category_id)
|
||
else:
|
||
results["+category-list-conversations"] = blocked(
|
||
"+category-list-conversations",
|
||
"账号下需要至少一个真实会话分组 categoryId",
|
||
)
|
||
|
||
candidates = merge_group_candidates(groups_all, groups_mine)
|
||
group = candidates[0] if candidates else {}
|
||
messages: Capture | None = None
|
||
best_group: dict[str, Any] = {}
|
||
best_score = -1
|
||
# A valid but quiet first group is not sufficient evidence for the
|
||
# message-dependent Shortcuts. Walk real joined groups until one produces a
|
||
# non-empty message page. Prefer a page that also supplies downstream
|
||
# fixtures (a message sent by the current user, media, or a thread ID).
|
||
# This prevents the audit from stopping at the first technically valid but
|
||
# fixture-poor group.
|
||
probe_candidates = candidates[:max_group_probes]
|
||
if group_override:
|
||
forced = next(
|
||
(
|
||
candidate
|
||
for candidate in candidates
|
||
if str(candidate.get("openConversationId") or "") == group_override
|
||
),
|
||
{"openConversationId": group_override},
|
||
)
|
||
probe_candidates = [forced]
|
||
for candidate in probe_candidates:
|
||
candidate_id = str(candidate.get("openConversationId") or "")
|
||
if not candidate_id:
|
||
continue
|
||
probe = run_capture(
|
||
binary,
|
||
"+messages-list",
|
||
[
|
||
"--group",
|
||
candidate_id,
|
||
"--time",
|
||
time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"--forward=false",
|
||
"--limit",
|
||
"50",
|
||
],
|
||
timeout,
|
||
)
|
||
probe_summary = summarize_capture(probe)
|
||
if probe_summary["status"] not in {"pass", "pass_empty"}:
|
||
continue
|
||
rows = first_list(probe.upper(), "messages")
|
||
own_open_id = (
|
||
str(candidate.get("ownerOpenDingtalkId") or "")
|
||
if str(candidate.get("myRole") or "") == "OWNER"
|
||
else ""
|
||
)
|
||
own_message = any(
|
||
isinstance(item, dict) and item.get("senderId") == own_open_id
|
||
for item in rows
|
||
)
|
||
probe_raw = probe.raw_calls[-1][2] if probe.raw_calls else {}
|
||
has_resource = bool(
|
||
first_string(probe_raw, "mediaId", "resourceId", "downloadCode")
|
||
)
|
||
has_thread = bool(
|
||
first_string(
|
||
probe_raw,
|
||
"openConvThreadId",
|
||
"openConversationThreadId",
|
||
"threadId",
|
||
"topicId",
|
||
)
|
||
)
|
||
score = (
|
||
int(probe_summary.get("upper", {}).get("count") or 0)
|
||
+ (1000 if own_message else 0)
|
||
+ (500 if has_resource else 0)
|
||
+ (500 if has_thread else 0)
|
||
)
|
||
if score > best_score:
|
||
best_score = score
|
||
best_group = candidate
|
||
messages = probe
|
||
if messages is not None:
|
||
group = best_group
|
||
|
||
group_id = str(group.get("openConversationId") or "")
|
||
group_name = str(group.get("title") or group.get("name") or "")
|
||
|
||
if group_id:
|
||
conversation = run("+conversation-info", "--group", group_id)
|
||
run("+chat-invite-url", "--group", group_id, "--expires-seconds", "60")
|
||
run("+chat-bots", "--group", group_id)
|
||
run("+chat-role-list", "--group", group_id)
|
||
run("+messages-list-pin", "--open-conversation-id", group_id, "--size", "100")
|
||
if messages is None:
|
||
messages = run(
|
||
"+messages-list",
|
||
"--group",
|
||
group_id,
|
||
"--time",
|
||
time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"--forward=false",
|
||
"--limit",
|
||
"50",
|
||
)
|
||
else:
|
||
captures["+messages-list"] = messages
|
||
results["+messages-list"] = summarize_capture(messages)
|
||
run(
|
||
"+chat-messages",
|
||
"--group",
|
||
group_id,
|
||
"--time",
|
||
time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"--direction",
|
||
"older",
|
||
"--limit",
|
||
"50",
|
||
retries=2,
|
||
)
|
||
if group_name:
|
||
run("+chat-search", "--query", group_name, "--limit", "20")
|
||
members = run("+group-members", "--group", group_name)
|
||
run("+chat-members-list", "--group", group_name)
|
||
else:
|
||
results["+chat-search"] = blocked("+chat-search", "真实群需要有可搜索标题")
|
||
results["+group-members"] = blocked("+group-members", "真实群需要有可搜索标题")
|
||
results["+chat-members-list"] = blocked(
|
||
"+chat-members-list", "真实群需要有可搜索标题"
|
||
)
|
||
members = None
|
||
|
||
numeric_group_id = numeric_group_override or first_string(
|
||
conversation.raw_calls[-1][2] if conversation.raw_calls else {},
|
||
"groupId",
|
||
"group_id",
|
||
)
|
||
if numeric_group_id and numeric_group_id.isdigit():
|
||
run("+chat-get-by-id", "--group-id", numeric_group_id)
|
||
else:
|
||
results["+chat-get-by-id"] = blocked(
|
||
"+chat-get-by-id",
|
||
"需要一个真实数字群号 groupId;openConversationId 不能替代",
|
||
)
|
||
|
||
member_rows = (
|
||
first_list(members.upper(), "members") if members is not None else []
|
||
)
|
||
member_open_id = first_string(
|
||
member_rows,
|
||
"openDingtalkId",
|
||
"openDingTalkId",
|
||
"memberDingtalkId",
|
||
)
|
||
if member_open_id:
|
||
run(
|
||
"+chat-members-get",
|
||
"--id",
|
||
group_id,
|
||
"--users",
|
||
member_open_id,
|
||
)
|
||
role_query_capture: Capture | None = None
|
||
for member in member_rows[:max_member_probes]:
|
||
candidate_open_id = first_string(
|
||
member,
|
||
"openDingtalkId",
|
||
"openDingTalkId",
|
||
"memberDingtalkId",
|
||
)
|
||
if not candidate_open_id:
|
||
continue
|
||
probe = run_capture(
|
||
binary,
|
||
"+chat-role-query-user",
|
||
["--group", group_id, "--user", candidate_open_id],
|
||
timeout,
|
||
)
|
||
role_query_capture = probe
|
||
if summarize_capture(probe)["status"] in {"pass", "pass_empty"}:
|
||
break
|
||
if role_query_capture is not None:
|
||
captures["+chat-role-query-user"] = role_query_capture
|
||
results["+chat-role-query-user"] = summarize_capture(
|
||
role_query_capture
|
||
)
|
||
direct_capture: Capture | None = None
|
||
cross_org_only = True
|
||
for member in member_rows[:max_member_probes]:
|
||
candidate_open_id = first_string(
|
||
member,
|
||
"openDingtalkId",
|
||
"openDingTalkId",
|
||
"memberDingtalkId",
|
||
)
|
||
if not candidate_open_id:
|
||
continue
|
||
probe = run_capture(
|
||
binary,
|
||
"+messages-list-direct",
|
||
[
|
||
"--open-dingtalk-id",
|
||
candidate_open_id,
|
||
"--time",
|
||
time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"--forward=false",
|
||
"--limit",
|
||
"20",
|
||
"--page-all",
|
||
"--page-limit",
|
||
"50",
|
||
],
|
||
timeout,
|
||
)
|
||
summary = summarize_capture(probe)
|
||
if summary["status"] in {"pass", "pass_empty"}:
|
||
direct_capture = probe
|
||
break
|
||
if summary.get("error", {}).get("server_error_code") != (
|
||
"CrossOrgPermissionDenied"
|
||
):
|
||
cross_org_only = False
|
||
direct_capture = probe
|
||
break
|
||
if direct_capture is not None:
|
||
captures["+messages-list-direct"] = direct_capture
|
||
results["+messages-list-direct"] = summarize_capture(direct_capture)
|
||
elif cross_org_only:
|
||
results["+messages-list-direct"] = blocked(
|
||
"+messages-list-direct",
|
||
"需要一个当前账号有权读取单聊记录的同组织成员",
|
||
)
|
||
else:
|
||
requirement = "需要一个可读取 openDingTalkId 的真实群成员"
|
||
for command in (
|
||
"+chat-members-get",
|
||
"+chat-role-query-user",
|
||
"+messages-list-direct",
|
||
):
|
||
results[command] = blocked(command, requirement)
|
||
|
||
message_rows = first_list(messages.upper(), "messages")
|
||
own_open_id = (
|
||
str(group.get("ownerOpenDingtalkId") or "")
|
||
if str(group.get("myRole") or "") == "OWNER"
|
||
else ""
|
||
)
|
||
own_message_rows = [
|
||
item
|
||
for item in message_rows
|
||
if isinstance(item, dict) and item.get("senderId") == own_open_id
|
||
]
|
||
message_id = first_string(
|
||
own_message_rows or message_rows,
|
||
"messageId",
|
||
"openMessageId",
|
||
"openMsgId",
|
||
)
|
||
if message_id:
|
||
run("+messages-mget", "--msg-ids", message_id)
|
||
if own_message_rows:
|
||
run(
|
||
"+messages-read-status",
|
||
"--conversation-id",
|
||
group_id,
|
||
"--message-id",
|
||
message_id,
|
||
)
|
||
else:
|
||
results["+messages-read-status"] = blocked(
|
||
"+messages-read-status",
|
||
"需要当前账号自己发送的一条真实消息 openMessageId",
|
||
)
|
||
else:
|
||
requirement = "需要该群内至少一条可读取的真实消息 openMessageId"
|
||
results["+messages-mget"] = blocked("+messages-mget", requirement)
|
||
results["+messages-read-status"] = blocked(
|
||
"+messages-read-status", requirement
|
||
)
|
||
|
||
keyword = safe_keyword(message_rows)
|
||
run(
|
||
"+search-msg",
|
||
"--group",
|
||
group_id,
|
||
"--query",
|
||
keyword,
|
||
"--days",
|
||
"30",
|
||
)
|
||
|
||
raw_messages = messages.raw_calls[-1][2] if messages.raw_calls else {}
|
||
topic_id = topic_id_override or first_string(
|
||
raw_messages,
|
||
"openConvThreadId",
|
||
"openConversationThreadId",
|
||
"threadId",
|
||
"topicId",
|
||
)
|
||
if topic_id:
|
||
topic_args = [
|
||
"--group",
|
||
topic_group_override or group_id,
|
||
"--topic-id",
|
||
topic_id,
|
||
]
|
||
if topic_page_size:
|
||
topic_args.extend(
|
||
[
|
||
"--limit",
|
||
topic_page_size,
|
||
"--page-all",
|
||
"--page-limit",
|
||
"50",
|
||
]
|
||
)
|
||
else:
|
||
topic_args.extend(
|
||
[
|
||
"--time",
|
||
time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"--limit",
|
||
"20",
|
||
]
|
||
)
|
||
run("+thread-replies", *topic_args)
|
||
else:
|
||
results["+thread-replies"] = blocked(
|
||
"+thread-replies",
|
||
"需要一个真实话题群及其 topic/thread ID",
|
||
)
|
||
|
||
resource_id = media_resource_override
|
||
resource_message_id = media_message_override
|
||
resource_group_id = media_group_override or group_id
|
||
for raw_message in first_list(raw_messages, "messages", "messageList"):
|
||
if resource_id and resource_message_id:
|
||
break
|
||
if not isinstance(raw_message, dict):
|
||
continue
|
||
candidate_resource_id = first_string(
|
||
raw_message,
|
||
"mediaId",
|
||
"resourceId",
|
||
"downloadCode",
|
||
)
|
||
candidate_message_id = first_string(
|
||
raw_message,
|
||
"openMessageId",
|
||
"openMsgId",
|
||
)
|
||
if candidate_resource_id and candidate_message_id:
|
||
resource_id = candidate_resource_id
|
||
resource_message_id = candidate_message_id
|
||
break
|
||
if resource_id and resource_message_id:
|
||
run(
|
||
"+messages-resource-url",
|
||
"--type",
|
||
"mediaId",
|
||
"--resource-id",
|
||
resource_id,
|
||
"--message-id",
|
||
resource_message_id,
|
||
"--open-conversation-id",
|
||
resource_group_id,
|
||
)
|
||
download_output = (
|
||
Path("tmp")
|
||
/ "im-shortcut-live-audit"
|
||
/ f".read-resource-{os.getpid()}-{time.time_ns()}.bin"
|
||
)
|
||
download_output.parent.mkdir(parents=True, exist_ok=True)
|
||
try:
|
||
download_capture = run(
|
||
"+messages-resource-download",
|
||
"--type",
|
||
"mediaId",
|
||
"--resource-id",
|
||
resource_id,
|
||
"--message-id",
|
||
resource_message_id,
|
||
"--open-conversation-id",
|
||
resource_group_id,
|
||
"--output",
|
||
str(download_output),
|
||
)
|
||
download_summary = results["+messages-resource-download"]
|
||
upper_download = download_capture.upper()
|
||
expected_size = (
|
||
upper_download.get("sizeBytes")
|
||
if isinstance(upper_download, dict)
|
||
else None
|
||
)
|
||
if (
|
||
download_summary["status"] == "pass"
|
||
and (
|
||
not download_output.is_file()
|
||
or not isinstance(expected_size, (int, float))
|
||
or download_output.stat().st_size != expected_size
|
||
)
|
||
):
|
||
download_summary["status"] = "local_artifact_mismatch"
|
||
finally:
|
||
download_output.unlink(missing_ok=True)
|
||
else:
|
||
results["+messages-resource-url"] = blocked(
|
||
"+messages-resource-url",
|
||
"需要一条含 mediaId 的真实图片/视频/语音消息",
|
||
)
|
||
results["+messages-resource-download"] = blocked(
|
||
"+messages-resource-download",
|
||
"需要一条含 mediaId 的真实图片/视频/语音消息",
|
||
)
|
||
else:
|
||
requirement = "当前账号需要至少加入一个可读真实群"
|
||
for command in (
|
||
"+conversation-info",
|
||
"+chat-invite-url",
|
||
"+chat-bots",
|
||
"+chat-role-list",
|
||
"+messages-list-pin",
|
||
"+messages-list",
|
||
"+chat-messages",
|
||
"+chat-search",
|
||
"+group-members",
|
||
"+chat-members-list",
|
||
"+chat-get-by-id",
|
||
"+chat-members-get",
|
||
"+chat-role-query-user",
|
||
"+messages-list-direct",
|
||
"+messages-mget",
|
||
"+messages-read-status",
|
||
"+search-msg",
|
||
"+thread-replies",
|
||
"+messages-resource-download",
|
||
"+messages-resource-url",
|
||
):
|
||
results[command] = blocked(command, requirement)
|
||
|
||
open_task_id = open_task_override
|
||
for capture in captures.values():
|
||
if open_task_id:
|
||
break
|
||
for _, _, raw in capture.raw_calls:
|
||
open_task_id = first_string(raw, "openTaskId", "taskId")
|
||
if open_task_id:
|
||
break
|
||
if open_task_id:
|
||
break
|
||
if open_task_id:
|
||
run("+messages-query-send-status", "--open-task-id", open_task_id)
|
||
else:
|
||
results["+messages-query-send-status"] = blocked(
|
||
"+messages-query-send-status",
|
||
"需要一次真实消息发送返回的 openTaskId",
|
||
)
|
||
|
||
return [results.get(command, blocked(command, "审计器未覆盖")) for command in READ_SHORTCUTS]
|
||
|
||
|
||
def report_markdown(results: list[dict[str, Any]]) -> str:
|
||
counts: dict[str, int] = {}
|
||
for result in results:
|
||
counts[result["status"]] = counts.get(result["status"], 0) + 1
|
||
lines = [
|
||
"# Chat Shortcut live read audit",
|
||
"",
|
||
"This report was produced through the real Shortcut entry point and the",
|
||
"lower MCP response captured from the same invocation. It intentionally",
|
||
"contains no business payload values.",
|
||
"",
|
||
f"- Total: {len(results)}",
|
||
]
|
||
for status in sorted(counts):
|
||
lines.append(f"- {status}: {counts[status]}")
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"| Shortcut | Status | Upper | Lower | Reactions U/L | Threads U/L | MCP calls | Evidence / requirement |",
|
||
"|---|---|---:|---:|---:|---:|---:|---|",
|
||
]
|
||
)
|
||
for result in results:
|
||
upper = result.get("upper", {}).get("count")
|
||
lower = result.get("lower", {}).get("count")
|
||
calls = result.get("lower", {}).get("call_count")
|
||
upper_reactions = result.get("upper", {}).get("reaction_message_count")
|
||
lower_reactions = result.get("lower", {}).get("reaction_message_count")
|
||
upper_threads = result.get("upper", {}).get("thread_message_count")
|
||
lower_threads = result.get("lower", {}).get("thread_message_count")
|
||
tools = ", ".join(result.get("lower", {}).get("tools", []))
|
||
evidence = result.get("requirement") or tools
|
||
lines.append(
|
||
f"| `{result['command']}` | {result['status']} | "
|
||
f"{'' if upper is None else upper} | "
|
||
f"{'' if lower is None else lower} | "
|
||
f"{'' if upper_reactions is None else upper_reactions}/"
|
||
f"{'' if lower_reactions is None else lower_reactions} | "
|
||
f"{'' if upper_threads is None else upper_threads}/"
|
||
f"{'' if lower_threads is None else lower_threads} | "
|
||
f"{'' if calls is None else calls} | {evidence} |"
|
||
)
|
||
lines.append("")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def validate_report(results: list[dict[str, Any]]) -> None:
|
||
commands = [result["command"] for result in results]
|
||
if tuple(commands) != READ_SHORTCUTS:
|
||
raise RuntimeError("read Shortcut audit coverage drifted from the expected set")
|
||
encoded = json.dumps(results, ensure_ascii=False)
|
||
for forbidden in ("stdout", "stderr", "backend_raw", "raw_backend", "inviteUrl"):
|
||
if f'"{forbidden}"' in encoded:
|
||
raise RuntimeError(f"sanitized report contains forbidden field {forbidden!r}")
|
||
if "https://" in encoded or "http://" in encoded:
|
||
raise RuntimeError("sanitized report appears to contain a URL")
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("--dws", required=True, type=Path, help="locally built dws binary")
|
||
parser.add_argument("--out-dir", required=True, type=Path)
|
||
parser.add_argument("--timeout", type=int, default=60)
|
||
parser.add_argument("--max-group-probes", type=int, default=8)
|
||
parser.add_argument("--max-member-probes", type=int, default=8)
|
||
args = parser.parse_args()
|
||
|
||
binary = args.dws.resolve()
|
||
if not binary.is_file():
|
||
parser.error(f"--dws is not a file: {binary}")
|
||
numeric_group_id = os.environ.get("DWS_AUDIT_NUMERIC_GROUP_ID", "").strip()
|
||
if numeric_group_id and not numeric_group_id.isdigit():
|
||
parser.error("DWS_AUDIT_NUMERIC_GROUP_ID must contain only digits")
|
||
topic_override = [
|
||
os.environ.get("DWS_AUDIT_TOPIC_GROUP_ID", "").strip(),
|
||
os.environ.get("DWS_AUDIT_TOPIC_ID", "").strip(),
|
||
]
|
||
if any(topic_override) and not all(topic_override):
|
||
parser.error("topic fixture override requires both group and topic IDs")
|
||
topic_page_size = os.environ.get("DWS_AUDIT_TOPIC_PAGE_SIZE", "").strip()
|
||
if topic_page_size and (
|
||
not topic_page_size.isdigit() or int(topic_page_size) < 1
|
||
):
|
||
parser.error("DWS_AUDIT_TOPIC_PAGE_SIZE must be a positive integer")
|
||
media_override = [
|
||
os.environ.get("DWS_AUDIT_MEDIA_GROUP_ID", "").strip(),
|
||
os.environ.get("DWS_AUDIT_MEDIA_MESSAGE_ID", "").strip(),
|
||
os.environ.get("DWS_AUDIT_MEDIA_RESOURCE_ID", "").strip(),
|
||
]
|
||
if any(media_override) and not all(media_override):
|
||
parser.error("media fixture override requires group, message, and resource IDs")
|
||
results = live_audit(
|
||
binary,
|
||
args.timeout,
|
||
args.max_group_probes,
|
||
args.max_member_probes,
|
||
)
|
||
validate_report(results)
|
||
|
||
args.out_dir.mkdir(parents=True, exist_ok=True)
|
||
json_path = args.out_dir / "chat-read-live-audit.json"
|
||
markdown_path = args.out_dir / "chat-read-live-audit.md"
|
||
json_path.write_text(
|
||
json.dumps(
|
||
{
|
||
"scope": "chat-read-shortcuts",
|
||
"count": len(results),
|
||
"results": results,
|
||
},
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
)
|
||
+ "\n",
|
||
encoding="utf-8",
|
||
)
|
||
markdown_path.write_text(report_markdown(results), encoding="utf-8")
|
||
print(markdown_path)
|
||
|
||
failures = {
|
||
"projection_mismatch",
|
||
"projection_unverified",
|
||
"pagination_mismatch",
|
||
"backend_error",
|
||
"cli_error",
|
||
"error_envelope",
|
||
"timeout",
|
||
"no_lower_capture",
|
||
"local_artifact_mismatch",
|
||
"incomplete",
|
||
}
|
||
return 1 if any(result["status"] in failures for result in results) else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|