opencode-skills-collection 4.1.18 → 4.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundled-skills/.antigravity-install-manifest.json +20 -1
- package/bundled-skills/ai-headshot-studio/SKILL.md +107 -99
- package/bundled-skills/ai-image-generation-studio/SKILL.md +108 -93
- package/bundled-skills/ai-logo-maker/SKILL.md +108 -93
- package/bundled-skills/ai-multilingual-dubbing/SKILL.md +108 -93
- package/bundled-skills/ai-photo-restyler/SKILL.md +108 -93
- package/bundled-skills/ai-podcast-voiceover/SKILL.md +108 -93
- package/bundled-skills/aident-loadout/SKILL.md +88 -0
- package/bundled-skills/antigravity-maintainer-batch-release/SKILL.md +27 -254
- package/bundled-skills/antigravity-maintainer-batch-release/agents/openai.yaml +1 -1
- package/bundled-skills/antigravity-maintainer-batch-release/references/component-validation.md +18 -0
- package/bundled-skills/antigravity-maintainer-batch-release/references/core-preview.md +20 -0
- package/bundled-skills/antigravity-maintainer-batch-release/references/maintenance-contract.md +83 -0
- package/bundled-skills/antigravity-maintainer-batch-release/references/maintenance-cycle.md +149 -0
- package/bundled-skills/antigravity-maintainer-batch-release/references/public-surfaces.md +22 -0
- package/bundled-skills/antigravity-maintainer-batch-release/references/skillspector.md +40 -0
- package/bundled-skills/apify-facebook-ads-library-scraper/SKILL.md +197 -0
- package/bundled-skills/apify-facebook-ads-library-scraper/references/gotchas.md +45 -0
- package/bundled-skills/apify-linkedin-jobs-scraper/SKILL.md +181 -0
- package/bundled-skills/apify-linkedin-jobs-scraper/references/gotchas.md +57 -0
- package/bundled-skills/apify-tiktok-scraper/SKILL.md +199 -0
- package/bundled-skills/apify-tiktok-scraper/references/gotchas.md +45 -0
- package/bundled-skills/arcmira-find-quotes/SKILL.md +5 -0
- package/bundled-skills/beatra/SKILL.md +109 -90
- package/bundled-skills/beatra-ai-video-studio/SKILL.md +110 -90
- package/bundled-skills/curlshot/SKILL.md +139 -0
- package/bundled-skills/datacircle/SKILL.md +159 -98
- package/bundled-skills/deed-page/SKILL.md +161 -0
- package/bundled-skills/design-token-contrast/SKILL.md +128 -0
- package/bundled-skills/design-token-contrast/references/contraste-checker.md +330 -0
- package/bundled-skills/ecommerce-listing-image-set/SKILL.md +109 -90
- package/bundled-skills/goal-instruction-writer/LICENSE +21 -0
- package/bundled-skills/goal-instruction-writer/SKILL.md +275 -0
- package/bundled-skills/goal-instruction-writer/references/contribution-walkthrough.md +9 -0
- package/bundled-skills/goal-instruction-writer/references/drift-control.md +160 -0
- package/bundled-skills/goal-instruction-writer/references/examples.md +220 -0
- package/bundled-skills/goal-instruction-writer/references/goal-contract.md +169 -0
- package/bundled-skills/goal-instruction-writer/references/loop-preview.md +113 -0
- package/bundled-skills/keenable-search-mcp/SKILL.md +176 -0
- package/bundled-skills/krexa/SKILL.md +221 -0
- package/bundled-skills/krexa/references/limits.md +42 -0
- package/bundled-skills/krexa/references/setup.md +60 -0
- package/bundled-skills/krexa/references/tools.md +59 -0
- package/bundled-skills/llm-cost-optimization/SKILL.md +1 -1
- package/bundled-skills/llmops-platform-engineering/SKILL.md +1 -1
- package/bundled-skills/multi-tenant-llm-hosting/SKILL.md +1 -1
- package/bundled-skills/music-generation-studio/SKILL.md +109 -90
- package/bundled-skills/outreachgenie-list-audit/LICENSE +21 -0
- package/bundled-skills/outreachgenie-list-audit/SKILL.md +60 -0
- package/bundled-skills/outreachgenie-list-audit/assets/fictional-snapshot.json +256 -0
- package/bundled-skills/outreachgenie-list-audit/references/capture.md +53 -0
- package/bundled-skills/outreachgenie-list-audit/references/checks.md +35 -0
- package/bundled-skills/outreachgenie-list-audit/references/setup.md +25 -0
- package/bundled-skills/outreachgenie-list-audit/scripts/audit.py +766 -0
- package/bundled-skills/outreachgenie-list-audit/scripts/test_audit.py +360 -0
- package/bundled-skills/pangolinfo-amazon-data-research/SKILL.md +170 -0
- package/bundled-skills/poster-design-studio/SKILL.md +109 -90
- package/bundled-skills/ppl-personal-crm/SKILL.md +118 -0
- package/bundled-skills/product-photo-studio/SKILL.md +109 -90
- package/bundled-skills/project-memory-log/LICENSE +21 -0
- package/bundled-skills/project-memory-log/SKILL.md +110 -0
- package/bundled-skills/project-memory-log/references/example-document-catalog.md +22 -0
- package/bundled-skills/project-memory-log/references/example-notes.md +16 -0
- package/bundled-skills/project-memory-log/references/example-operation-log.md +46 -0
- package/bundled-skills/project-memory-log/references/record-formats.md +77 -0
- package/bundled-skills/solidping/SKILL.md +87 -0
- package/bundled-skills/story-bible-continuity/SKILL.md +149 -0
- package/bundled-skills/story-bible-continuity/references/sbc-checker.md +367 -0
- package/bundled-skills/story-bible-continuity/references/story-bible-schema.md +91 -0
- package/bundled-skills/suno-lyrics-to-song/SKILL.md +109 -90
- package/bundled-skills/taasky/SKILL.md +376 -0
- package/bundled-skills/talking-avatar-video/SKILL.md +109 -90
- package/bundled-skills/uiverify-triage-visual-changes/SKILL.md +304 -0
- package/bundled-skills/unpaid-intern/SKILL.md +159 -0
- package/bundled-skills/viral-video-teardown-remake/SKILL.md +109 -90
- package/bundled-skills/voice-cloning-studio/SKILL.md +109 -90
- package/bundled-skills/voiceover-narration-studio/SKILL.md +109 -90
- package/package.json +1 -1
- package/skills_index.json +654 -24
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
"""Audit an explicitly captured OutreachGenie snapshot. No network/API calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import html
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from uuid import UUID
|
|
15
|
+
|
|
16
|
+
VERSION = "0.1.0"
|
|
17
|
+
BUILTINS = {
|
|
18
|
+
"first_name",
|
|
19
|
+
"last_name",
|
|
20
|
+
"company",
|
|
21
|
+
"title",
|
|
22
|
+
"email",
|
|
23
|
+
"phone",
|
|
24
|
+
"linkedin_url",
|
|
25
|
+
}
|
|
26
|
+
PRIVATE_FIELDS = {"email", "phone"}
|
|
27
|
+
TOKEN = re.compile(r"{{\s*([^{}]+?)\s*}}")
|
|
28
|
+
ALLOWED_TOOLS = {
|
|
29
|
+
"get_workspace_context",
|
|
30
|
+
"get_flow",
|
|
31
|
+
"list_flow_template_fields",
|
|
32
|
+
"get_prospect_list",
|
|
33
|
+
"list_prospects_in_list",
|
|
34
|
+
}
|
|
35
|
+
LIMITS = {
|
|
36
|
+
"send_connection_request": ("invite_note", 200, False, "utf16"),
|
|
37
|
+
"send_message": ("message", 1000, True, "code_points"),
|
|
38
|
+
"comment_post": ("comment_text", 1000, True, "code_points"),
|
|
39
|
+
}
|
|
40
|
+
MAX_BYTES = 5 * 1024 * 1024
|
|
41
|
+
MAX_RECORDS = 1000
|
|
42
|
+
MAX_FINDINGS = 10000
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class InputError(ValueError):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def require(condition, message):
|
|
50
|
+
if not condition:
|
|
51
|
+
raise InputError(message)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_snapshot(path):
|
|
55
|
+
"""Read bounded, unambiguous UTF-8 JSON without printing captured values."""
|
|
56
|
+
|
|
57
|
+
def object_from_pairs(pairs):
|
|
58
|
+
result = {}
|
|
59
|
+
for key, value in pairs:
|
|
60
|
+
require(key not in result, "Snapshot contains duplicate JSON object keys.")
|
|
61
|
+
result[key] = value
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
def finite_float(value):
|
|
65
|
+
parsed = float(value)
|
|
66
|
+
require(math.isfinite(parsed), "Snapshot contains a non-finite number.")
|
|
67
|
+
return parsed
|
|
68
|
+
|
|
69
|
+
def reject_constant(_value):
|
|
70
|
+
raise InputError("Snapshot contains a non-finite number.")
|
|
71
|
+
|
|
72
|
+
with path.open("rb") as handle:
|
|
73
|
+
raw = handle.read(MAX_BYTES + 1)
|
|
74
|
+
require(len(raw) <= MAX_BYTES, "Snapshot exceeds 5 MiB.")
|
|
75
|
+
try:
|
|
76
|
+
snapshot = json.loads(
|
|
77
|
+
raw.decode("utf-8"),
|
|
78
|
+
object_pairs_hook=object_from_pairs,
|
|
79
|
+
parse_float=finite_float,
|
|
80
|
+
parse_constant=reject_constant,
|
|
81
|
+
)
|
|
82
|
+
pending = [snapshot]
|
|
83
|
+
while pending:
|
|
84
|
+
value = pending.pop()
|
|
85
|
+
if isinstance(value, str):
|
|
86
|
+
value.encode("utf-8")
|
|
87
|
+
elif isinstance(value, dict):
|
|
88
|
+
pending.extend(value.keys())
|
|
89
|
+
pending.extend(value.values())
|
|
90
|
+
elif isinstance(value, list):
|
|
91
|
+
pending.extend(value)
|
|
92
|
+
return snapshot
|
|
93
|
+
except InputError:
|
|
94
|
+
raise
|
|
95
|
+
except (UnicodeError, ValueError, RecursionError) as error:
|
|
96
|
+
raise InputError(
|
|
97
|
+
"Snapshot must be valid UTF-8 JSON with supported nesting and numeric values."
|
|
98
|
+
) from error
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def uuid(value):
|
|
102
|
+
require(isinstance(value, str), "Expected a UUID string.")
|
|
103
|
+
try:
|
|
104
|
+
return str(UUID(value))
|
|
105
|
+
except ValueError as error:
|
|
106
|
+
raise InputError("Invalid UUID in snapshot.") from error
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def normalize_key(raw):
|
|
110
|
+
key = raw.strip()
|
|
111
|
+
if not key or any(c.isspace() for c in key):
|
|
112
|
+
raise ValueError("empty_or_spaced_token")
|
|
113
|
+
if key.startswith("custom_fields."):
|
|
114
|
+
custom = key[len("custom_fields.") :].strip().lower()
|
|
115
|
+
if not re.fullmatch(r"[a-z0-9_]{1,64}", custom):
|
|
116
|
+
raise ValueError("invalid_custom_token")
|
|
117
|
+
return "custom", custom
|
|
118
|
+
if not re.fullmatch(r"[a-z][a-z0-9_]*", key):
|
|
119
|
+
raise ValueError("invalid_token")
|
|
120
|
+
if key in BUILTINS:
|
|
121
|
+
return "builtin", key
|
|
122
|
+
if key == "note":
|
|
123
|
+
raise ValueError("note_is_not_a_template_field")
|
|
124
|
+
if not re.fullmatch(r"[a-z0-9_]{1,64}", key):
|
|
125
|
+
raise ValueError("invalid_custom_token")
|
|
126
|
+
return "custom", key
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse_template(text):
|
|
130
|
+
refs, errors, spans = [], [], []
|
|
131
|
+
for match in TOKEN.finditer(text):
|
|
132
|
+
spans.append(match.span())
|
|
133
|
+
try:
|
|
134
|
+
ref = normalize_key(match.group(1))
|
|
135
|
+
if ref not in refs:
|
|
136
|
+
refs.append(ref)
|
|
137
|
+
except ValueError as error:
|
|
138
|
+
errors.append(str(error))
|
|
139
|
+
remaining = text
|
|
140
|
+
for start, end in reversed(spans):
|
|
141
|
+
remaining = remaining[:start] + remaining[end:]
|
|
142
|
+
if "{{" in remaining or "}}" in remaining:
|
|
143
|
+
errors.append("unbalanced_template_braces")
|
|
144
|
+
return refs, sorted(set(errors))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def saved_value(record, ref):
|
|
148
|
+
kind, key = ref
|
|
149
|
+
if kind == "builtin":
|
|
150
|
+
if key not in record:
|
|
151
|
+
return None
|
|
152
|
+
value = record[key]
|
|
153
|
+
else:
|
|
154
|
+
if "custom_fields" not in record or not isinstance(
|
|
155
|
+
record["custom_fields"], dict
|
|
156
|
+
):
|
|
157
|
+
return None
|
|
158
|
+
value = record["custom_fields"].get(key, "")
|
|
159
|
+
if value is not None and not isinstance(value, (str, bool, int, float)):
|
|
160
|
+
return None
|
|
161
|
+
return str(value or "")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def render(text, record):
|
|
165
|
+
"""Runtime-compatible single substitution pass; caller checks syntax/coverage."""
|
|
166
|
+
|
|
167
|
+
def replacement(match):
|
|
168
|
+
try:
|
|
169
|
+
return saved_value(record, normalize_key(match.group(1))) or ""
|
|
170
|
+
except ValueError:
|
|
171
|
+
return ""
|
|
172
|
+
|
|
173
|
+
return TOKEN.sub(replacement, text).strip()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def length(text, unit):
|
|
177
|
+
return (
|
|
178
|
+
sum(2 if ord(c) > 0xFFFF else 1 for c in text) if unit == "utf16" else len(text)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def rendered_length(text, record, unit):
|
|
183
|
+
"""Measure stripped single-pass output without allocating expanded messages."""
|
|
184
|
+
cached = {}
|
|
185
|
+
|
|
186
|
+
def stats(value):
|
|
187
|
+
size = length(value, unit)
|
|
188
|
+
leading = length(value[: len(value) - len(value.lstrip())], unit)
|
|
189
|
+
trailing = length(value[len(value.rstrip()) :], unit)
|
|
190
|
+
return size, leading, trailing
|
|
191
|
+
|
|
192
|
+
def chunks():
|
|
193
|
+
end = 0
|
|
194
|
+
for match in TOKEN.finditer(text):
|
|
195
|
+
yield stats(text[end : match.start()])
|
|
196
|
+
ref = normalize_key(match.group(1))
|
|
197
|
+
if ref not in cached:
|
|
198
|
+
cached[ref] = stats(saved_value(record, ref) or "")
|
|
199
|
+
yield cached[ref]
|
|
200
|
+
end = match.end()
|
|
201
|
+
yield stats(text[end:])
|
|
202
|
+
|
|
203
|
+
started, total, trailing_total = False, 0, 0
|
|
204
|
+
for size, leading, trailing in chunks():
|
|
205
|
+
if not started:
|
|
206
|
+
if leading == size:
|
|
207
|
+
continue
|
|
208
|
+
started = True
|
|
209
|
+
total += size - leading
|
|
210
|
+
trailing_total = trailing
|
|
211
|
+
else:
|
|
212
|
+
total += size
|
|
213
|
+
trailing_total = trailing_total + size if leading == size else trailing
|
|
214
|
+
return total - trailing_total
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def text_fields(nodes):
|
|
218
|
+
require(
|
|
219
|
+
isinstance(nodes, list) and len(nodes) <= 200,
|
|
220
|
+
"Flow nodes must be a list of at most 200 nodes.",
|
|
221
|
+
)
|
|
222
|
+
seen = set()
|
|
223
|
+
fields, issues, unchecked = [], [], []
|
|
224
|
+
for node in nodes:
|
|
225
|
+
require(isinstance(node, dict), "Flow node must be an object.")
|
|
226
|
+
nid = node.get("id")
|
|
227
|
+
require(
|
|
228
|
+
isinstance(nid, str) and 0 < len(nid) <= 200 and nid not in seen,
|
|
229
|
+
"Missing or duplicate flow node ID.",
|
|
230
|
+
)
|
|
231
|
+
seen.add(nid)
|
|
232
|
+
if node.get("type") != "action":
|
|
233
|
+
continue
|
|
234
|
+
data = node.get("data")
|
|
235
|
+
require(isinstance(data, dict), "Action node data must be an object.")
|
|
236
|
+
action, config = data.get("actionKey"), data.get("config")
|
|
237
|
+
require(
|
|
238
|
+
isinstance(action, str) and isinstance(config, dict),
|
|
239
|
+
"Action key/config is missing.",
|
|
240
|
+
)
|
|
241
|
+
if action == "message_sequence":
|
|
242
|
+
messages = config.get("messages")
|
|
243
|
+
if not isinstance(messages, list) or not messages:
|
|
244
|
+
issues.append((nid, "unconfigured_message_sequence"))
|
|
245
|
+
continue
|
|
246
|
+
if not 2 <= len(messages) <= 10:
|
|
247
|
+
issues.append((nid, "message_sequence_count_outside_product_limit"))
|
|
248
|
+
sequence_ids = [
|
|
249
|
+
entry.get("id") if isinstance(entry, dict) else None
|
|
250
|
+
for entry in messages
|
|
251
|
+
]
|
|
252
|
+
if any(
|
|
253
|
+
not isinstance(value, str) or not value.strip()
|
|
254
|
+
for value in sequence_ids
|
|
255
|
+
) or len({str(value) for value in sequence_ids}) != len(sequence_ids):
|
|
256
|
+
issues.append((nid, "message_sequence_ids_invalid"))
|
|
257
|
+
for index, message in enumerate(messages):
|
|
258
|
+
if not isinstance(message, dict) or not isinstance(
|
|
259
|
+
message.get("message"), str
|
|
260
|
+
):
|
|
261
|
+
issues.append((nid, "message_text_unavailable"))
|
|
262
|
+
continue
|
|
263
|
+
fields.append(
|
|
264
|
+
(
|
|
265
|
+
nid,
|
|
266
|
+
action,
|
|
267
|
+
f"messages[{index}].message",
|
|
268
|
+
message["message"],
|
|
269
|
+
1000,
|
|
270
|
+
True,
|
|
271
|
+
"code_points",
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
elif action in LIMITS:
|
|
275
|
+
name, limit, required, unit = LIMITS[action]
|
|
276
|
+
camel = name.split("_")[0] + "".join(x.title() for x in name.split("_")[1:])
|
|
277
|
+
if camel != name and name in config and camel in config:
|
|
278
|
+
issues.append((nid, "ambiguous_text_field_aliases"))
|
|
279
|
+
continue
|
|
280
|
+
value = config.get(name)
|
|
281
|
+
if value is None:
|
|
282
|
+
value = config.get(camel)
|
|
283
|
+
if value is None and not required:
|
|
284
|
+
value = ""
|
|
285
|
+
if not isinstance(value, str):
|
|
286
|
+
issues.append((nid, "message_text_unavailable"))
|
|
287
|
+
continue
|
|
288
|
+
fields.append((nid, action, name, value, limit, required, unit))
|
|
289
|
+
else:
|
|
290
|
+
unchecked.append({"node_id": nid, "action_key": action})
|
|
291
|
+
require(len(fields) <= 500, "Snapshot exceeds 500 outbound text fields.")
|
|
292
|
+
return fields, issues, unchecked
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def audit(snapshot):
|
|
296
|
+
require(
|
|
297
|
+
isinstance(snapshot, dict)
|
|
298
|
+
and type(snapshot.get("schema_version")) is int
|
|
299
|
+
and snapshot.get("schema_version") == 1,
|
|
300
|
+
"Expected snapshot schema_version 1.",
|
|
301
|
+
)
|
|
302
|
+
require(
|
|
303
|
+
isinstance(snapshot.get("source_kind"), str)
|
|
304
|
+
and snapshot.get("source_kind") in {"fictional_fixture", "mcp_snapshot"},
|
|
305
|
+
"Declare fictional_fixture or mcp_snapshot.",
|
|
306
|
+
)
|
|
307
|
+
captured = snapshot.get("captured_at")
|
|
308
|
+
require(isinstance(captured, str), "captured_at is required.")
|
|
309
|
+
try:
|
|
310
|
+
parsed_date = datetime.fromisoformat(captured.replace("Z", "+00:00"))
|
|
311
|
+
require(parsed_date.tzinfo is not None, "captured_at requires a timezone.")
|
|
312
|
+
except ValueError as error:
|
|
313
|
+
raise InputError("captured_at must be an ISO timestamp.") from error
|
|
314
|
+
workspace_id, flow_id = (
|
|
315
|
+
uuid(snapshot.get("workspace_id")),
|
|
316
|
+
uuid(snapshot.get("flow_id")),
|
|
317
|
+
)
|
|
318
|
+
ids = snapshot.get("selected_list_ids")
|
|
319
|
+
require(
|
|
320
|
+
isinstance(ids, list) and 1 <= len(ids) <= 20, "Select one to twenty list IDs."
|
|
321
|
+
)
|
|
322
|
+
ids = [uuid(x) for x in ids]
|
|
323
|
+
require(len(set(ids)) == len(ids), "Selected list IDs must be unique.")
|
|
324
|
+
calls = snapshot.get("calls")
|
|
325
|
+
require(
|
|
326
|
+
isinstance(calls, list) and len(calls) <= 1000,
|
|
327
|
+
"Expected at most 1000 captured tool calls.",
|
|
328
|
+
)
|
|
329
|
+
grouped = {name: [] for name in ALLOWED_TOOLS}
|
|
330
|
+
for call in calls:
|
|
331
|
+
require(
|
|
332
|
+
isinstance(call, dict)
|
|
333
|
+
and isinstance(call.get("tool"), str)
|
|
334
|
+
and call.get("tool") in ALLOWED_TOOLS,
|
|
335
|
+
"Only the five documented read tools belong in this snapshot.",
|
|
336
|
+
)
|
|
337
|
+
require(
|
|
338
|
+
isinstance(call.get("arguments"), dict)
|
|
339
|
+
and isinstance(call.get("result"), dict),
|
|
340
|
+
"Call arguments and unwrapped result must be objects.",
|
|
341
|
+
)
|
|
342
|
+
grouped[call["tool"]].append(call)
|
|
343
|
+
for singleton in ["get_workspace_context", "get_flow", "list_flow_template_fields"]:
|
|
344
|
+
require(
|
|
345
|
+
len(grouped[singleton]) == 1, f"Exactly one {singleton} result is required."
|
|
346
|
+
)
|
|
347
|
+
context = grouped["get_workspace_context"][0]
|
|
348
|
+
require(
|
|
349
|
+
isinstance(context["result"].get("workspace"), dict),
|
|
350
|
+
"Workspace context was not captured.",
|
|
351
|
+
)
|
|
352
|
+
require(
|
|
353
|
+
uuid(context["result"].get("workspace", {}).get("id")) == workspace_id,
|
|
354
|
+
"Connected workspace does not match requested workspace.",
|
|
355
|
+
)
|
|
356
|
+
flow_call = grouped["get_flow"][0]
|
|
357
|
+
require(
|
|
358
|
+
uuid(flow_call["arguments"].get("flow_id")) == flow_id
|
|
359
|
+
and uuid(flow_call["result"].get("id")) == flow_id,
|
|
360
|
+
"Flow ID mismatch.",
|
|
361
|
+
)
|
|
362
|
+
schema_fields = grouped["list_flow_template_fields"][0]["result"].get(
|
|
363
|
+
"built_in_fields"
|
|
364
|
+
)
|
|
365
|
+
require(
|
|
366
|
+
isinstance(schema_fields, list)
|
|
367
|
+
and all(
|
|
368
|
+
isinstance(x, dict) and isinstance(x.get("key"), str) for x in schema_fields
|
|
369
|
+
),
|
|
370
|
+
"Template-field catalog is missing.",
|
|
371
|
+
)
|
|
372
|
+
require(
|
|
373
|
+
{x.get("key") for x in schema_fields} == BUILTINS,
|
|
374
|
+
"Built-in template catalog changed; review the helper contract before continuing.",
|
|
375
|
+
)
|
|
376
|
+
fields, config_issues, unchecked = text_fields(flow_call["result"].get("nodes"))
|
|
377
|
+
findings = []
|
|
378
|
+
|
|
379
|
+
def add(code, level="review", **evidence):
|
|
380
|
+
require(
|
|
381
|
+
len(findings) < MAX_FINDINGS,
|
|
382
|
+
"Snapshot produces over 10000 findings; reduce the selected-list scope and rerun.",
|
|
383
|
+
)
|
|
384
|
+
findings.append({"code": code, "level": level, **evidence})
|
|
385
|
+
|
|
386
|
+
for node_id, code in config_issues:
|
|
387
|
+
add(code, "unsupported", node_id=node_id)
|
|
388
|
+
refs_by_field, custom_keys = {}, set()
|
|
389
|
+
for node_id, action, field, text, limit, required, unit in fields:
|
|
390
|
+
require(
|
|
391
|
+
len(text) <= 100000, "Individual template exceeds the audit input limit."
|
|
392
|
+
)
|
|
393
|
+
refs, errors = parse_template(text)
|
|
394
|
+
refs_by_field[(node_id, field)] = (refs, errors)
|
|
395
|
+
custom_keys.update(key for kind, key in refs if kind == "custom")
|
|
396
|
+
for error in errors:
|
|
397
|
+
add(error, "template_issue", node_id=node_id, field=field)
|
|
398
|
+
if action == "send_connection_request" and length(text.strip(), "utf16") > 200:
|
|
399
|
+
add(
|
|
400
|
+
"raw_invitation_note_exceeds_product_limit",
|
|
401
|
+
"template_issue",
|
|
402
|
+
node_id=node_id,
|
|
403
|
+
field=field,
|
|
404
|
+
length=length(text.strip(), "utf16"),
|
|
405
|
+
limit=200,
|
|
406
|
+
unit="utf16",
|
|
407
|
+
)
|
|
408
|
+
if action in {"send_message", "message_sequence"} and len(text.strip()) > 1000:
|
|
409
|
+
add(
|
|
410
|
+
"raw_message_exceeds_product_limit",
|
|
411
|
+
"template_issue",
|
|
412
|
+
node_id=node_id,
|
|
413
|
+
field=field,
|
|
414
|
+
length=len(text.strip()),
|
|
415
|
+
limit=1000,
|
|
416
|
+
unit="code_points",
|
|
417
|
+
)
|
|
418
|
+
if required and not text.strip():
|
|
419
|
+
add(
|
|
420
|
+
"required_raw_text_empty",
|
|
421
|
+
"template_issue",
|
|
422
|
+
node_id=node_id,
|
|
423
|
+
field=field,
|
|
424
|
+
)
|
|
425
|
+
for kind, key in refs:
|
|
426
|
+
if kind == "builtin" and key in PRIVATE_FIELDS:
|
|
427
|
+
add(
|
|
428
|
+
"privacy_sensitive_template_field",
|
|
429
|
+
"context",
|
|
430
|
+
node_id=node_id,
|
|
431
|
+
field=field,
|
|
432
|
+
template_field=key,
|
|
433
|
+
)
|
|
434
|
+
details, pages = {}, {lid: [] for lid in ids}
|
|
435
|
+
for call in grouped["get_prospect_list"]:
|
|
436
|
+
lid = uuid(call["arguments"].get("list_id"))
|
|
437
|
+
require(
|
|
438
|
+
lid in ids and lid not in details and uuid(call["result"].get("id")) == lid,
|
|
439
|
+
"Unexpected, duplicate or mismatched list detail.",
|
|
440
|
+
)
|
|
441
|
+
details[lid] = call["result"]
|
|
442
|
+
require(set(details) == set(ids), "Each selected list requires one detail result.")
|
|
443
|
+
for call in grouped["list_prospects_in_list"]:
|
|
444
|
+
lid = uuid(call["arguments"].get("list_id"))
|
|
445
|
+
require(lid in ids, "Prospect page belongs to an unselected list.")
|
|
446
|
+
pages[lid].append(call)
|
|
447
|
+
coverage, records_by_list, total_records = [], {}, 0
|
|
448
|
+
for lid in ids:
|
|
449
|
+
detail = details[lid]
|
|
450
|
+
metadata = detail.get("custom_field_metadata")
|
|
451
|
+
require(
|
|
452
|
+
isinstance(metadata, list) and all(isinstance(x, dict) for x in metadata),
|
|
453
|
+
"List field metadata was not captured.",
|
|
454
|
+
)
|
|
455
|
+
available = {x.get("key") for x in metadata if isinstance(x.get("key"), str)}
|
|
456
|
+
for key in sorted(custom_keys - available):
|
|
457
|
+
add(
|
|
458
|
+
"custom_field_missing_from_list_metadata",
|
|
459
|
+
"template_issue",
|
|
460
|
+
list_id=lid,
|
|
461
|
+
template_field=key,
|
|
462
|
+
)
|
|
463
|
+
active_count = detail.get("active_campaign_count")
|
|
464
|
+
if type(active_count) is int and active_count > 0:
|
|
465
|
+
add(
|
|
466
|
+
"list_has_active_campaigns",
|
|
467
|
+
"context",
|
|
468
|
+
list_id=lid,
|
|
469
|
+
active_campaign_count=active_count,
|
|
470
|
+
)
|
|
471
|
+
elif type(active_count) is not int or active_count < 0:
|
|
472
|
+
add("active_campaign_context_unavailable", "unknown", list_id=lid)
|
|
473
|
+
expected = detail.get("prospect_count")
|
|
474
|
+
require(
|
|
475
|
+
type(expected) is int and expected >= 0,
|
|
476
|
+
"List prospect_count must be a nonnegative integer.",
|
|
477
|
+
)
|
|
478
|
+
collected, issues, seen_ids = [], [], set()
|
|
479
|
+
ordered = pages[lid]
|
|
480
|
+
next_expected, final_seen, first_size = 1, False, None
|
|
481
|
+
for call in ordered:
|
|
482
|
+
args, result = call["arguments"], call["result"]
|
|
483
|
+
query = args.get("query")
|
|
484
|
+
require(
|
|
485
|
+
isinstance(query, dict) and set(query) <= {"page", "page_size"},
|
|
486
|
+
"Capture an unfiltered page with explicit page/page_size.",
|
|
487
|
+
)
|
|
488
|
+
page, size = query.get("page"), query.get("page_size")
|
|
489
|
+
require(
|
|
490
|
+
type(page) is int
|
|
491
|
+
and page >= 1
|
|
492
|
+
and type(size) is int
|
|
493
|
+
and 1 <= size <= 100,
|
|
494
|
+
"Invalid page/page_size arguments.",
|
|
495
|
+
)
|
|
496
|
+
if first_size is None:
|
|
497
|
+
first_size = size
|
|
498
|
+
elif size != first_size:
|
|
499
|
+
issues.append("page_size_changed")
|
|
500
|
+
items, info = result.get("items"), result.get("pagination")
|
|
501
|
+
require(
|
|
502
|
+
isinstance(items, list) and isinstance(info, dict),
|
|
503
|
+
"Items and normalized pagination are required.",
|
|
504
|
+
)
|
|
505
|
+
require(len(items) <= size, "Page contains more records than requested.")
|
|
506
|
+
if page != next_expected or final_seen:
|
|
507
|
+
issues.append("noncontiguous_or_extra_pages")
|
|
508
|
+
if (
|
|
509
|
+
any(
|
|
510
|
+
type(info.get(key)) is not int
|
|
511
|
+
for key in ["page", "page_size", "total"]
|
|
512
|
+
)
|
|
513
|
+
or info.get("page") != page
|
|
514
|
+
or info.get("page_size") != size
|
|
515
|
+
or info.get("total") != expected
|
|
516
|
+
):
|
|
517
|
+
issues.append("pagination_or_total_mismatch")
|
|
518
|
+
has_more = info.get("has_more")
|
|
519
|
+
if type(has_more) is not bool:
|
|
520
|
+
issues.append("missing_has_more")
|
|
521
|
+
elif (has_more and type(info.get("next_page")) is not int) or info.get(
|
|
522
|
+
"next_page"
|
|
523
|
+
) != (page + 1 if has_more else None):
|
|
524
|
+
issues.append("next_page_mismatch")
|
|
525
|
+
previous = info.get("previous_page")
|
|
526
|
+
if (page > 1 and type(previous) is not int) or previous != (
|
|
527
|
+
page - 1 if page > 1 else None
|
|
528
|
+
):
|
|
529
|
+
issues.append("previous_page_mismatch")
|
|
530
|
+
if type(has_more) is bool and has_more != (page * size < expected):
|
|
531
|
+
issues.append("has_more_disagrees_with_total")
|
|
532
|
+
if len(items) != max(0, min(size, expected - (page - 1) * size)):
|
|
533
|
+
issues.append("unexpected_page_length")
|
|
534
|
+
if has_more is False:
|
|
535
|
+
final_seen = True
|
|
536
|
+
next_expected = page + 1
|
|
537
|
+
for item in items:
|
|
538
|
+
require(isinstance(item, dict), "Prospect item must be an object.")
|
|
539
|
+
pid = uuid(item.get("id"))
|
|
540
|
+
if pid in seen_ids:
|
|
541
|
+
issues.append("duplicate_record_across_pages")
|
|
542
|
+
continue
|
|
543
|
+
seen_ids.add(pid)
|
|
544
|
+
collected.append({**item, "id": pid})
|
|
545
|
+
total_records += len(collected)
|
|
546
|
+
require(
|
|
547
|
+
total_records <= MAX_RECORDS,
|
|
548
|
+
f"Snapshot exceeds {MAX_RECORDS} selected-list memberships; capture a bounded scope and report partial coverage.",
|
|
549
|
+
)
|
|
550
|
+
if not ordered or not final_seen or len(collected) != expected:
|
|
551
|
+
issues.append("incomplete_collection")
|
|
552
|
+
for issue in sorted(set(issues)):
|
|
553
|
+
add(issue, "coverage_issue", list_id=lid)
|
|
554
|
+
coverage.append(
|
|
555
|
+
{
|
|
556
|
+
"list_id": lid,
|
|
557
|
+
"reported_memberships": expected,
|
|
558
|
+
"processed_memberships": len(collected),
|
|
559
|
+
"pages": len(ordered),
|
|
560
|
+
"complete": not issues,
|
|
561
|
+
}
|
|
562
|
+
)
|
|
563
|
+
records_by_list[lid] = collected
|
|
564
|
+
if expected == 0:
|
|
565
|
+
add("empty_selected_list", "context", list_id=lid)
|
|
566
|
+
for lid, records in records_by_list.items():
|
|
567
|
+
for record in records:
|
|
568
|
+
pid = uuid(record["id"])
|
|
569
|
+
for node_id, action, field, text, limit, required, unit in fields:
|
|
570
|
+
refs, errors = refs_by_field[(node_id, field)]
|
|
571
|
+
if errors:
|
|
572
|
+
continue
|
|
573
|
+
unknown = False
|
|
574
|
+
for ref in refs:
|
|
575
|
+
value = saved_value(record, ref)
|
|
576
|
+
key = f"custom_fields.{ref[1]}" if ref[0] == "custom" else ref[1]
|
|
577
|
+
evidence = {
|
|
578
|
+
"list_id": lid,
|
|
579
|
+
"prospect_id": pid,
|
|
580
|
+
"node_id": node_id,
|
|
581
|
+
"field": field,
|
|
582
|
+
"template_field": key,
|
|
583
|
+
}
|
|
584
|
+
if value is None:
|
|
585
|
+
unknown = True
|
|
586
|
+
add(
|
|
587
|
+
"saved_value_not_captured_or_unsupported",
|
|
588
|
+
"unknown",
|
|
589
|
+
**evidence,
|
|
590
|
+
)
|
|
591
|
+
elif value == "":
|
|
592
|
+
add("saved_template_value_empty", "review", **evidence)
|
|
593
|
+
elif "{{" in value or "}}" in value:
|
|
594
|
+
add(
|
|
595
|
+
"saved_value_contains_literal_braces", "context", **evidence
|
|
596
|
+
)
|
|
597
|
+
if unknown:
|
|
598
|
+
continue
|
|
599
|
+
measured_length = rendered_length(text, record, unit)
|
|
600
|
+
evidence = {
|
|
601
|
+
"list_id": lid,
|
|
602
|
+
"prospect_id": pid,
|
|
603
|
+
"node_id": node_id,
|
|
604
|
+
"field": field,
|
|
605
|
+
}
|
|
606
|
+
if measured_length == 0:
|
|
607
|
+
add(
|
|
608
|
+
"required_text_renders_empty"
|
|
609
|
+
if required
|
|
610
|
+
else "optional_note_renders_empty",
|
|
611
|
+
"template_issue" if required else "context",
|
|
612
|
+
**evidence,
|
|
613
|
+
)
|
|
614
|
+
elif measured_length > limit:
|
|
615
|
+
add(
|
|
616
|
+
"rendered_text_exceeds_product_limit",
|
|
617
|
+
"template_issue",
|
|
618
|
+
length=measured_length,
|
|
619
|
+
limit=limit,
|
|
620
|
+
unit=unit,
|
|
621
|
+
**evidence,
|
|
622
|
+
)
|
|
623
|
+
if not fields:
|
|
624
|
+
add("no_supported_outbound_text_fields", "unsupported")
|
|
625
|
+
counts = dict(sorted(Counter(f["code"] for f in findings).items()))
|
|
626
|
+
return {
|
|
627
|
+
"audit_version": VERSION,
|
|
628
|
+
"source_kind": snapshot["source_kind"],
|
|
629
|
+
"captured_at": captured,
|
|
630
|
+
"workspace_id": workspace_id,
|
|
631
|
+
"flow_id": flow_id,
|
|
632
|
+
"coverage": coverage,
|
|
633
|
+
"all_selected_memberships_captured": all(x["complete"] for x in coverage),
|
|
634
|
+
"unique_prospects_processed": len(
|
|
635
|
+
{x["id"] for records in records_by_list.values() for x in records}
|
|
636
|
+
),
|
|
637
|
+
"outbound_text_fields_checked": len(fields),
|
|
638
|
+
"unchecked_actions": unchecked,
|
|
639
|
+
"counts": counts,
|
|
640
|
+
"findings": findings,
|
|
641
|
+
"limits": [
|
|
642
|
+
"Supplied snapshot only; authentication and live workspace state are not independently verified.",
|
|
643
|
+
"No graph reachability, schedule, reply-state, contact permission, profile validity or delivery audit.",
|
|
644
|
+
"Findings omit raw saved values and rendered message text; record IDs and field names may still be sensitive.",
|
|
645
|
+
"A complete snapshot or zero findings is not approval to activate or send.",
|
|
646
|
+
"Field truth, relevance and whether a message is appropriate require human judgement.",
|
|
647
|
+
],
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def markdown(report):
|
|
652
|
+
def cell(value):
|
|
653
|
+
return (
|
|
654
|
+
html.escape(str(value))
|
|
655
|
+
.replace("\\", "\\\\")
|
|
656
|
+
.replace("|", "\\|")
|
|
657
|
+
.replace("\n", " ")
|
|
658
|
+
.replace("\r", " ")
|
|
659
|
+
.replace("`", "\\`")
|
|
660
|
+
.replace("[", "\\[")
|
|
661
|
+
.replace("]", "\\]")
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
lines = [
|
|
665
|
+
"# OutreachGenie saved-list/flow audit",
|
|
666
|
+
"",
|
|
667
|
+
f"Source: {report['source_kind']}; captured: {report['captured_at']}",
|
|
668
|
+
"",
|
|
669
|
+
"This report checks a supplied snapshot. It does not approve sending.",
|
|
670
|
+
"",
|
|
671
|
+
"## Coverage",
|
|
672
|
+
"",
|
|
673
|
+
"| List ID | Reported memberships | Processed | Complete |",
|
|
674
|
+
"| --- | ---: | ---: | --- |",
|
|
675
|
+
]
|
|
676
|
+
for item in report["coverage"]:
|
|
677
|
+
lines.append(
|
|
678
|
+
f"| {item['list_id']} | {item['reported_memberships']} | {item['processed_memberships']} | {item['complete']} |"
|
|
679
|
+
)
|
|
680
|
+
lines += [
|
|
681
|
+
"",
|
|
682
|
+
"## Findings",
|
|
683
|
+
"",
|
|
684
|
+
"| Finding | Level | List ID | Prospect ID | Node | Text field | Template field | Detail |",
|
|
685
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
686
|
+
]
|
|
687
|
+
for item in report["findings"]:
|
|
688
|
+
detail = ""
|
|
689
|
+
if "length" in item:
|
|
690
|
+
detail = f"{item['length']} / {item['limit']} {item['unit']}"
|
|
691
|
+
elif "active_campaign_count" in item:
|
|
692
|
+
detail = f"{item['active_campaign_count']} active campaign(s)"
|
|
693
|
+
lines.append(
|
|
694
|
+
"| "
|
|
695
|
+
+ " | ".join(
|
|
696
|
+
cell(item.get(k, ""))
|
|
697
|
+
for k in [
|
|
698
|
+
"code",
|
|
699
|
+
"level",
|
|
700
|
+
"list_id",
|
|
701
|
+
"prospect_id",
|
|
702
|
+
"node_id",
|
|
703
|
+
"field",
|
|
704
|
+
"template_field",
|
|
705
|
+
]
|
|
706
|
+
)
|
|
707
|
+
+ " | "
|
|
708
|
+
+ cell(detail)
|
|
709
|
+
+ " |"
|
|
710
|
+
)
|
|
711
|
+
if not report["findings"]:
|
|
712
|
+
lines += [
|
|
713
|
+
"",
|
|
714
|
+
"No findings from the checks performed. This is not a readiness certification.",
|
|
715
|
+
]
|
|
716
|
+
lines += ["", "## Limits", ""] + ["- " + x for x in report["limits"]]
|
|
717
|
+
lines += [
|
|
718
|
+
"",
|
|
719
|
+
"Other saved actions were not evaluated: "
|
|
720
|
+
+ ", ".join(cell(x["action_key"]) for x in report["unchecked_actions"])
|
|
721
|
+
+ ".",
|
|
722
|
+
"",
|
|
723
|
+
]
|
|
724
|
+
return "\n".join(lines)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def main():
|
|
728
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
729
|
+
parser.add_argument("--input", type=Path, required=True)
|
|
730
|
+
parser.add_argument(
|
|
731
|
+
"--output-dir",
|
|
732
|
+
type=Path,
|
|
733
|
+
required=True,
|
|
734
|
+
help="New private directory; existing directories are not overwritten.",
|
|
735
|
+
)
|
|
736
|
+
args = parser.parse_args()
|
|
737
|
+
try:
|
|
738
|
+
snapshot = load_snapshot(args.input)
|
|
739
|
+
report = audit(snapshot)
|
|
740
|
+
args.output_dir.mkdir(mode=0o700, parents=False, exist_ok=False)
|
|
741
|
+
for name, content in [
|
|
742
|
+
(
|
|
743
|
+
"audit-report.json",
|
|
744
|
+
json.dumps(report, ensure_ascii=False, indent=2) + "\n",
|
|
745
|
+
),
|
|
746
|
+
("audit-report.md", markdown(report)),
|
|
747
|
+
]:
|
|
748
|
+
path = args.output_dir / name
|
|
749
|
+
with path.open("x", encoding="utf-8") as handle:
|
|
750
|
+
os.chmod(path, 0o600)
|
|
751
|
+
handle.write(content)
|
|
752
|
+
print(
|
|
753
|
+
json.dumps(
|
|
754
|
+
{
|
|
755
|
+
"report_directory": str(args.output_dir),
|
|
756
|
+
"findings": len(report["findings"]),
|
|
757
|
+
"coverage_complete": report["all_selected_memberships_captured"],
|
|
758
|
+
}
|
|
759
|
+
)
|
|
760
|
+
)
|
|
761
|
+
except (InputError, OSError, json.JSONDecodeError) as error:
|
|
762
|
+
parser.exit(2, f"Audit could not run: {error}\n")
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
if __name__ == "__main__":
|
|
766
|
+
main()
|