@cohortapp/agent-sdk 2.10.0 → 2.11.0
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/.claude/commands/init-maestro.md +16 -9
- package/docs/guides/mac-mini.md +11 -1
- package/docs/runbooks/cohort-cutover.md +16 -0
- package/lib/mcp/server.test.mjs +16 -4
- package/lib/org/client.mjs +58 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +98 -0
- package/lib/org/protocol.test.mjs +19 -2
- package/lib/org/resource-tools.mjs +317 -0
- package/lib/org/resource-tools.test.mjs +361 -0
- package/lib/org/tool-access.mjs +176 -0
- package/lib/org/tool-access.test.mjs +144 -0
- package/lib/org/tool-surface.mjs +431 -5
- package/lib/org/tool-surface.test.mjs +385 -8
- package/lib/org/ui-parity.mjs +196 -3
- package/lib/org/ui-parity.test.mjs +126 -7
- package/lib/tool-definitions.js +23 -2
- package/package.json +2 -2
- package/plugins/maestro-skills/.claude-plugin/marketplace.json +1 -1
- package/plugins/maestro-skills/plugin.json +4 -0
- package/plugins/maestro-skills/skills/venture-deliverables.md +176 -0
- package/policies/information-barriers.yaml +34 -7
- package/scripts/ci/check-no-residual-identity.mjs +281 -9
- package/scripts/ci/check-no-residual-identity.test.mjs +115 -2
- package/scripts/cloud-relay/voice/relay-identity.test.mjs +96 -0
- package/scripts/cloud-relay/voice/server.mjs +42 -2
- package/scripts/cost/track-claude-usage-pricing.test.mjs +183 -0
- package/scripts/cost/track-claude-usage.mjs +113 -4
- package/scripts/daemon/agent-daemon.mjs +150 -3
- package/scripts/daemon/agent-daemon.test.mjs +190 -0
- package/scripts/daemon/assurance.mjs +38 -15
- package/scripts/daemon/assurance.test.mjs +39 -1
- package/scripts/daemon/classifier-identity.test.mjs +137 -0
- package/scripts/daemon/classifier.mjs +98 -17
- package/scripts/daemon/prompt-builder-preamble.test.mjs +210 -0
- package/scripts/daemon/prompt-builder.mjs +264 -41
- package/scripts/daemon/prompt-builder.test.mjs +5 -5
- package/scripts/disclosure_boundaries.py +56 -5
- package/scripts/huddle/huddle-prompt.test.mjs +176 -0
- package/scripts/huddle/huddle-server.mjs +128 -13
- package/scripts/local-triggers/autoupdate.sh +83 -0
- package/scripts/local-triggers/generate-plists.sh +9 -0
- package/scripts/local-triggers/generate-plists.test.mjs +12 -10
- package/scripts/media-generation/brand-clause.test.mjs +135 -0
- package/scripts/media-generation/gemini-image-client.mjs +27 -9
- package/scripts/media-generation/generate-assets.mjs +102 -7
- package/scripts/pre-draft-context.py +91 -15
- package/scripts/spawn-session.sh +36 -6
- package/scripts/test-employer-grounding.py +348 -0
- package/scripts/validate_outbound.py +190 -26
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""test-employer-grounding.py — the send-path Python must read the employer, never assume one.
|
|
3
|
+
|
|
4
|
+
WHAT BROKE
|
|
5
|
+
scripts/validate_outbound.py carried SIXTEEN hardcoded 'company:<example>-*'
|
|
6
|
+
entity ids plus a matching `startswith` prefix test, and built its
|
|
7
|
+
possessive-claim regex and AI-self-disclosure business exceptions around the
|
|
8
|
+
same invented name. This file is Layer 0 of the sanctioned send path
|
|
9
|
+
(send-email-threaded.py imports validate_message; it runs in series with
|
|
10
|
+
lib/comms/send-gate.mjs#screenOutbound and either can block), so on any REAL
|
|
11
|
+
deployment nothing matched: every colleague was classified external, every
|
|
12
|
+
legitimate "our engineer" was flagged, and the AI-disclosure exceptions never
|
|
13
|
+
fired. scripts/pre-draft-context.py had the same id set, and
|
|
14
|
+
scripts/disclosure_boundaries.py rendered "Relationship to <invented company>"
|
|
15
|
+
into every clearance summary.
|
|
16
|
+
|
|
17
|
+
WHAT THIS PINS
|
|
18
|
+
1. GROUNDED — with a real config/company.json, the employer's entity ids,
|
|
19
|
+
possessive regex and AI-product exceptions all resolve from it, and the
|
|
20
|
+
clearance summary names the real company.
|
|
21
|
+
2. EMPTY — with the scaffold's UNCONFIGURED config, the id set is EMPTY and
|
|
22
|
+
`is_own_entity()` is False for everything. That is the FAIL-CLOSED
|
|
23
|
+
direction on every consumer: unknown people are external, possessive claims
|
|
24
|
+
stay flagged, and no company-specific AI-disclosure exception fires.
|
|
25
|
+
|
|
26
|
+
The modules read config from $AGENT_DIR (validate_outbound) / the repo root
|
|
27
|
+
(pre-draft-context, disclosure_boundaries), and resolve the employer at IMPORT
|
|
28
|
+
time, so each case is loaded in a fresh subprocess against a fresh fixture root.
|
|
29
|
+
|
|
30
|
+
Usage: python3 scripts/test-employer-grounding.py (exit 0 ok / 1 failures)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import shutil
|
|
36
|
+
import subprocess
|
|
37
|
+
import sys
|
|
38
|
+
import tempfile
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
42
|
+
REPO_ROOT = SCRIPT_DIR.parent
|
|
43
|
+
|
|
44
|
+
REAL_COMPANY = {
|
|
45
|
+
"name": "Meridian Freight",
|
|
46
|
+
"legalName": "Meridian Freight Holdings Ltd",
|
|
47
|
+
"domain": "meridianfreight.com",
|
|
48
|
+
}
|
|
49
|
+
SCAFFOLD_COMPANY = {
|
|
50
|
+
"name": "UNCONFIGURED",
|
|
51
|
+
"legalName": "",
|
|
52
|
+
"domain": "",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_passed = 0
|
|
56
|
+
_failed = 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def check(name, condition, detail=""):
|
|
60
|
+
global _passed, _failed
|
|
61
|
+
if condition:
|
|
62
|
+
_passed += 1
|
|
63
|
+
print(f" PASS: {name}")
|
|
64
|
+
else:
|
|
65
|
+
_failed += 1
|
|
66
|
+
print(f" FAIL: {name}{' — ' + detail if detail else ''}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def make_root(company):
|
|
70
|
+
"""A throwaway agent repo carrying only config/company.json."""
|
|
71
|
+
root = Path(tempfile.mkdtemp(prefix="employer-"))
|
|
72
|
+
(root / "config").mkdir(parents=True)
|
|
73
|
+
(root / "config" / "company.json").write_text(json.dumps(company))
|
|
74
|
+
return root
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run_probe(module_path, root, body, extra_env=None):
|
|
78
|
+
"""Import `module_path` with the repo rooted at `root`, run `body`, print JSON.
|
|
79
|
+
|
|
80
|
+
The module is loaded by absolute path with AGENT_DIR pointed at the fixture,
|
|
81
|
+
and — for the modules that resolve the repo root from __file__ — a COPY of
|
|
82
|
+
the script placed inside the fixture's scripts/ dir.
|
|
83
|
+
"""
|
|
84
|
+
probe = f'''
|
|
85
|
+
import importlib.util, json, sys
|
|
86
|
+
spec = importlib.util.spec_from_file_location("under_test", {str(module_path)!r})
|
|
87
|
+
m = importlib.util.module_from_spec(spec)
|
|
88
|
+
spec.loader.exec_module(m)
|
|
89
|
+
print("@@" + json.dumps({body}))
|
|
90
|
+
'''
|
|
91
|
+
env = dict(os.environ)
|
|
92
|
+
env["AGENT_DIR"] = str(root)
|
|
93
|
+
if extra_env:
|
|
94
|
+
env.update(extra_env)
|
|
95
|
+
out = subprocess.run(
|
|
96
|
+
[sys.executable, "-c", probe], capture_output=True, text=True, env=env, cwd=str(root)
|
|
97
|
+
)
|
|
98
|
+
if out.returncode != 0:
|
|
99
|
+
raise AssertionError(f"probe failed: {out.stderr.strip()[:800]}")
|
|
100
|
+
line = [ln for ln in out.stdout.splitlines() if ln.startswith("@@")][-1]
|
|
101
|
+
return json.loads(line[2:])
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def staged_copy(root, rel):
|
|
105
|
+
"""Copy a repo script into the fixture root so REPO_ROOT-from-__file__ resolves there.
|
|
106
|
+
|
|
107
|
+
HARNESS ADAPTATION (not a code change): scripts/disclosure_boundaries.py uses
|
|
108
|
+
PEP 604 annotations (`dict | None`), which are evaluated at def time before
|
|
109
|
+
Python 3.10 — a PRE-EXISTING 3.10+ requirement of that file, unrelated to
|
|
110
|
+
this lane. Prepending `from __future__ import annotations` to the COPY makes
|
|
111
|
+
annotations lazy so the real, unmodified function bodies can be exercised on
|
|
112
|
+
the 3.9 interpreter this machine ships. Nothing else about the file changes.
|
|
113
|
+
"""
|
|
114
|
+
dst = root / rel
|
|
115
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
src = (REPO_ROOT / rel).read_text()
|
|
117
|
+
dst.write_text("from __future__ import annotations\n" + src)
|
|
118
|
+
return dst
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── validate_outbound.py ─────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
def test_validate_outbound_grounded():
|
|
124
|
+
print("\n[validate_outbound — GROUNDED]")
|
|
125
|
+
root = make_root(REAL_COMPANY)
|
|
126
|
+
r = run_probe(
|
|
127
|
+
REPO_ROOT / "scripts" / "validate_outbound.py",
|
|
128
|
+
root,
|
|
129
|
+
'{'
|
|
130
|
+
'"name": m.COMPANY_NAME,'
|
|
131
|
+
'"ids": sorted(m.OWN_ENTITY_IDS),'
|
|
132
|
+
'"own_exact": m.is_own_entity("company:meridian-freight"),'
|
|
133
|
+
'"own_subsidiary": m.is_own_entity("company:meridian-freight-uk"),'
|
|
134
|
+
'"other": m.is_own_entity("company:someone-else"),'
|
|
135
|
+
'"ai_exception": len(m.check_ai_self_disclosure("Our Meridian Freight AI platform ships next week.")),'
|
|
136
|
+
'}',
|
|
137
|
+
)
|
|
138
|
+
check("employer name read from config", r["name"] == "Meridian Freight", str(r["name"]))
|
|
139
|
+
check("entity ids derive from name/legalName/domain", "company:meridian-freight" in r["ids"], str(r["ids"]))
|
|
140
|
+
check("own entity resolves", r["own_exact"] is True)
|
|
141
|
+
check("group subsidiary resolves via prefix", r["own_subsidiary"] is True)
|
|
142
|
+
check("an unrelated company does NOT resolve", r["other"] is False)
|
|
143
|
+
check("'<Employer> AI' is a business exception, not a self-disclosure", r["ai_exception"] == 0, str(r["ai_exception"]))
|
|
144
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_validate_outbound_empty():
|
|
148
|
+
print("\n[validate_outbound — EMPTY / fail closed]")
|
|
149
|
+
root = make_root(SCAFFOLD_COMPANY)
|
|
150
|
+
r = run_probe(
|
|
151
|
+
REPO_ROOT / "scripts" / "validate_outbound.py",
|
|
152
|
+
root,
|
|
153
|
+
'{'
|
|
154
|
+
'"name": m.COMPANY_NAME,'
|
|
155
|
+
'"ids": sorted(m.OWN_ENTITY_IDS),'
|
|
156
|
+
'"prefixes": sorted(m.OWN_ENTITY_PREFIXES),'
|
|
157
|
+
'"any": m.is_own_entity("company:anything-at-all"),'
|
|
158
|
+
'"none": m.is_own_entity(""),'
|
|
159
|
+
'}',
|
|
160
|
+
)
|
|
161
|
+
check("UNCONFIGURED reads as absent", r["name"] == "", repr(r["name"]))
|
|
162
|
+
check("NO employer ids are invented", r["ids"] == [], str(r["ids"]))
|
|
163
|
+
check("NO prefixes are invented", r["prefixes"] == [], str(r["prefixes"]))
|
|
164
|
+
check("is_own_entity is False for everything (fail closed)", r["any"] is False)
|
|
165
|
+
check("is_own_entity tolerates junk", r["none"] is False)
|
|
166
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_legacy_profile_key():
|
|
170
|
+
print("\n[validate_outbound — legacy profile key, read generically]")
|
|
171
|
+
root = make_root(REAL_COMPANY)
|
|
172
|
+
r = run_probe(
|
|
173
|
+
REPO_ROOT / "scripts" / "validate_outbound.py",
|
|
174
|
+
root,
|
|
175
|
+
'{'
|
|
176
|
+
'"canonical": m.legacy_relationship_value({"relationship_to_company": "advisor"}),'
|
|
177
|
+
'"legacy": m.legacy_relationship_value({"relationship_to_someoldcompany": "vendor"}),'
|
|
178
|
+
'"precedence": m.legacy_relationship_value({"relationship_to_company": "advisor", "relationship_to_old": "vendor"}),'
|
|
179
|
+
'"none": m.legacy_relationship_value({"unrelated": "x"}),'
|
|
180
|
+
'"junk": m.legacy_relationship_value(None),'
|
|
181
|
+
'}',
|
|
182
|
+
)
|
|
183
|
+
check("canonical key wins", r["canonical"] == "advisor")
|
|
184
|
+
check("ANY relationship_to_* key resolves (no company name pinned)", r["legacy"] == "vendor")
|
|
185
|
+
check("canonical takes precedence over legacy", r["precedence"] == "advisor")
|
|
186
|
+
check("absent ⇒ empty string", r["none"] == "")
|
|
187
|
+
check("junk ⇒ empty string", r["junk"] == "")
|
|
188
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ── pre-draft-context.py ─────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
def test_pre_draft_context():
|
|
194
|
+
print("\n[pre-draft-context — GROUNDED and EMPTY]")
|
|
195
|
+
for label, company, expect_own in (("grounded", REAL_COMPANY, True), ("empty", SCAFFOLD_COMPANY, False)):
|
|
196
|
+
root = make_root(company)
|
|
197
|
+
staged = staged_copy(root, "scripts/pre-draft-context.py")
|
|
198
|
+
r = run_probe(
|
|
199
|
+
staged,
|
|
200
|
+
root,
|
|
201
|
+
'{'
|
|
202
|
+
'"name": m.COMPANY_NAME,'
|
|
203
|
+
'"own": m.is_own_entity("company:meridian-freight"),'
|
|
204
|
+
'"other": m.is_own_entity("company:someone-else"),'
|
|
205
|
+
'}',
|
|
206
|
+
)
|
|
207
|
+
check(f"{label}: own-employer test is {expect_own}", r["own"] is expect_own, str(r))
|
|
208
|
+
check(f"{label}: an unrelated company is never own", r["other"] is False)
|
|
209
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ── disclosure_boundaries.py ─────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
def test_disclosure_boundaries():
|
|
215
|
+
print("\n[disclosure_boundaries — clearance summary names the REAL employer]")
|
|
216
|
+
root = make_root(REAL_COMPANY)
|
|
217
|
+
staged = staged_copy(root, "scripts/disclosure_boundaries.py")
|
|
218
|
+
r = run_probe(
|
|
219
|
+
staged,
|
|
220
|
+
root,
|
|
221
|
+
'{'
|
|
222
|
+
'"name": m.COMPANY_NAME,'
|
|
223
|
+
'"summary": m._build_clearance_summary('
|
|
224
|
+
' {"name": "Sam Rivera", "role": "Advisor", "relationship_to_company": "external advisor"},'
|
|
225
|
+
' {"domains_of_interest": ["engineering"]}),'
|
|
226
|
+
'"legacy_summary": m._build_clearance_summary('
|
|
227
|
+
' {"name": "Sam Rivera", "relationship_to_someoldcompany": "external advisor"},'
|
|
228
|
+
' {"domains_of_interest": []}),'
|
|
229
|
+
'}',
|
|
230
|
+
)
|
|
231
|
+
check("employer name read from config", r["name"] == "Meridian Freight")
|
|
232
|
+
check("summary names the real employer", "Relationship to Meridian Freight: external advisor." in r["summary"], r["summary"])
|
|
233
|
+
check("a legacy relationship_to_* key still resolves", "external advisor" in r["legacy_summary"], r["legacy_summary"])
|
|
234
|
+
check("no permitted domains ⇒ default deny is stated", "default deny" in r["legacy_summary"], r["legacy_summary"])
|
|
235
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
236
|
+
|
|
237
|
+
print("\n[disclosure_boundaries — EMPTY: a neutral noun, never an invented name]")
|
|
238
|
+
root = make_root(SCAFFOLD_COMPANY)
|
|
239
|
+
staged = staged_copy(root, "scripts/disclosure_boundaries.py")
|
|
240
|
+
r = run_probe(
|
|
241
|
+
staged,
|
|
242
|
+
root,
|
|
243
|
+
'{'
|
|
244
|
+
'"name": m.COMPANY_NAME,'
|
|
245
|
+
'"summary": m._build_clearance_summary('
|
|
246
|
+
' {"name": "Sam Rivera", "relationship_to_company": "external advisor"},'
|
|
247
|
+
' {"domains_of_interest": ["engineering"]}),'
|
|
248
|
+
'}',
|
|
249
|
+
)
|
|
250
|
+
check("UNCONFIGURED reads as absent", r["name"] == "")
|
|
251
|
+
check("summary falls back to 'the company'", "Relationship to the company: external advisor." in r["summary"], r["summary"])
|
|
252
|
+
check("no sentinel leaks into the summary", "UNCONFIGURED" not in r["summary"], r["summary"])
|
|
253
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ── policies/information-barriers.yaml ───────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
def test_information_barriers_policy():
|
|
259
|
+
print("\n[information-barriers.yaml — company-neutral, default-deny]")
|
|
260
|
+
raw = (REPO_ROOT / "policies" / "information-barriers.yaml").read_text()
|
|
261
|
+
descriptions = [ln for ln in raw.splitlines() if ln.strip().startswith("description:")]
|
|
262
|
+
check("domain descriptions exist", len(descriptions) > 10, str(len(descriptions)))
|
|
263
|
+
# Descriptions are parsed into the live keyword matcher by
|
|
264
|
+
# context-compiler.mjs#buildDomainKeywords, so a party name here becomes a
|
|
265
|
+
# classification keyword for every customer.
|
|
266
|
+
for ln in descriptions:
|
|
267
|
+
low = ln.lower()
|
|
268
|
+
check(
|
|
269
|
+
f"description is company-neutral: {ln.strip()[:60]}",
|
|
270
|
+
"northwind" not in low and "partnerco" not in low,
|
|
271
|
+
)
|
|
272
|
+
check("unrestricted_recipients ships EMPTY (default deny)", "unrestricted_recipients: []" in raw)
|
|
273
|
+
check("the taxonomy itself is kept", "internal-legal:" in raw and "board-governance:" in raw)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ── spawn-session.sh ─────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
def test_spawn_session_identity():
|
|
279
|
+
print("\n[spawn-session.sh — identity clause from config/agent.env]")
|
|
280
|
+
src = (REPO_ROOT / "scripts" / "spawn-session.sh").read_text()
|
|
281
|
+
check("sources config/agent.env", 'config/agent.env' in src)
|
|
282
|
+
check(
|
|
283
|
+
"uses the real config/agent.env variable names",
|
|
284
|
+
"AGENT_FULL_NAME" in src and "COMPANY_NAME" in src,
|
|
285
|
+
)
|
|
286
|
+
check("no invented employer in the sub-session prompt", "northwind" not in src.lower())
|
|
287
|
+
check("the instance-specific dir variable is renamed", "SOPHIE_AI_DIR" not in src)
|
|
288
|
+
|
|
289
|
+
# Behavioural: the clause is assembled by the shell, so drive the shell.
|
|
290
|
+
def clause(env):
|
|
291
|
+
script = (
|
|
292
|
+
'set -e\n'
|
|
293
|
+
'_agent_name="${AGENT_FULL_NAME:-}"\n'
|
|
294
|
+
'_company="${COMPANY_NAME:-}"\n'
|
|
295
|
+
'if [ "$_agent_name" = "UNCONFIGURED" ]; then _agent_name=""; fi\n'
|
|
296
|
+
'if [ "$_company" = "UNCONFIGURED" ]; then _company=""; fi\n'
|
|
297
|
+
'if [ -n "$_agent_name" ] && [ -n "$_company" ]; then\n'
|
|
298
|
+
' echo "IMPORTANT: You are a sub-session spawned for $_agent_name at $_company."\n'
|
|
299
|
+
'elif [ -n "$_agent_name" ]; then\n'
|
|
300
|
+
' echo "IMPORTANT: You are a sub-session spawned for $_agent_name."\n'
|
|
301
|
+
'else\n'
|
|
302
|
+
' echo "IMPORTANT: You are a sub-session spawned by an agent."\n'
|
|
303
|
+
'fi\n'
|
|
304
|
+
)
|
|
305
|
+
# The block under test is lifted verbatim from the script — assert that first.
|
|
306
|
+
assert 'if [ -n "$_agent_name" ] && [ -n "$_company" ]; then' in src, "identity block changed shape"
|
|
307
|
+
e = dict(os.environ)
|
|
308
|
+
e.pop("AGENT_FULL_NAME", None)
|
|
309
|
+
e.pop("COMPANY_NAME", None)
|
|
310
|
+
e.update(env)
|
|
311
|
+
return subprocess.run(["bash", "-c", script], capture_output=True, text=True, env=e).stdout.strip()
|
|
312
|
+
|
|
313
|
+
check(
|
|
314
|
+
"grounded: both fields present ⇒ full clause",
|
|
315
|
+
clause({"AGENT_FULL_NAME": "Robin Okafor", "COMPANY_NAME": "Meridian Freight"})
|
|
316
|
+
== "IMPORTANT: You are a sub-session spawned for Robin Okafor at Meridian Freight.",
|
|
317
|
+
)
|
|
318
|
+
check(
|
|
319
|
+
"empty: no company ⇒ the employer clause is dropped",
|
|
320
|
+
clause({"AGENT_FULL_NAME": "Robin Okafor"})
|
|
321
|
+
== "IMPORTANT: You are a sub-session spawned for Robin Okafor.",
|
|
322
|
+
)
|
|
323
|
+
check(
|
|
324
|
+
"empty: UNCONFIGURED counts as absent",
|
|
325
|
+
clause({"AGENT_FULL_NAME": "UNCONFIGURED", "COMPANY_NAME": "UNCONFIGURED"})
|
|
326
|
+
== "IMPORTANT: You are a sub-session spawned by an agent.",
|
|
327
|
+
)
|
|
328
|
+
check(
|
|
329
|
+
"empty: nothing at all ⇒ a generic, true statement",
|
|
330
|
+
clause({}) == "IMPORTANT: You are a sub-session spawned by an agent.",
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def main():
|
|
335
|
+
print("Running employer-grounding tests...\n")
|
|
336
|
+
test_validate_outbound_grounded()
|
|
337
|
+
test_validate_outbound_empty()
|
|
338
|
+
test_legacy_profile_key()
|
|
339
|
+
test_pre_draft_context()
|
|
340
|
+
test_disclosure_boundaries()
|
|
341
|
+
test_information_barriers_policy()
|
|
342
|
+
test_spawn_session_identity()
|
|
343
|
+
print(f"\n{_passed} passed, {_failed} failed")
|
|
344
|
+
return 1 if _failed else 0
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
if __name__ == "__main__":
|
|
348
|
+
sys.exit(main())
|
|
@@ -58,6 +58,152 @@ except Exception:
|
|
|
58
58
|
_AGENT = {"firstName": "Agent", "fullName": "Agent"}
|
|
59
59
|
AGENT_FIRST_NAME = _AGENT.get("firstName", "Agent")
|
|
60
60
|
|
|
61
|
+
|
|
62
|
+
# ─────────────────────────────────────────────
|
|
63
|
+
# Employer identity — resolved ONCE at load from config/company.json
|
|
64
|
+
#
|
|
65
|
+
# WHY THIS EXISTS
|
|
66
|
+
# The entity-relationship checks below used to carry sixteen hardcoded
|
|
67
|
+
# entity ids for the framework's example employer, plus a matching
|
|
68
|
+
# `startswith` prefix test. This file is Layer 0 of the sanctioned send path
|
|
69
|
+
# (send-email-threaded.py imports validate_message; it runs in series with
|
|
70
|
+
# lib/comms/send-gate.mjs#screenOutbound and either can block), so on any real
|
|
71
|
+
# deployment NOTHING matched: every colleague was classified external, every
|
|
72
|
+
# legitimate "our engineer" was flagged as an unverified possessive claim, and
|
|
73
|
+
# the AI-self-disclosure business exceptions never fired.
|
|
74
|
+
#
|
|
75
|
+
# Employer identity now comes from config/company.json — the same SOT
|
|
76
|
+
# lib/setup/enroll-from-cohort.mjs syncs from Cohort — exactly as
|
|
77
|
+
# scripts/poller/gmail-poller.mjs#internalDomains was de-hardcoded.
|
|
78
|
+
#
|
|
79
|
+
# FAIL CLOSED
|
|
80
|
+
# When nothing is configured the id set is EMPTY, so `is_own_entity()` is
|
|
81
|
+
# always False. That is the conservative direction on every consumer here:
|
|
82
|
+
# unknown-relationship people are treated as external, possessive claims stay
|
|
83
|
+
# flagged, and the AI-disclosure exception list stays empty (so "AI" wording
|
|
84
|
+
# is flagged rather than waved through). Silence, not a guess.
|
|
85
|
+
# ─────────────────────────────────────────────
|
|
86
|
+
try:
|
|
87
|
+
with open(REPO_ROOT / "config" / "company.json") as _f:
|
|
88
|
+
_COMPANY = json.load(_f)
|
|
89
|
+
except Exception:
|
|
90
|
+
_COMPANY = {}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _configured(value):
|
|
94
|
+
"""Return a trimmed config string, or '' when it is absent/UNCONFIGURED.
|
|
95
|
+
|
|
96
|
+
config/company.json ships `"name": "UNCONFIGURED"` and empty strings in the
|
|
97
|
+
scaffold, so both shapes mean 'the operator has not filled this in'.
|
|
98
|
+
"""
|
|
99
|
+
if not isinstance(value, str):
|
|
100
|
+
return ''
|
|
101
|
+
v = value.strip()
|
|
102
|
+
if not v or v.upper() == 'UNCONFIGURED':
|
|
103
|
+
return ''
|
|
104
|
+
return v
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
COMPANY_NAME = _configured(_COMPANY.get('name'))
|
|
108
|
+
COMPANY_LEGAL_NAME = _configured(_COMPANY.get('legalName'))
|
|
109
|
+
COMPANY_DOMAIN = _configured(_COMPANY.get('domain'))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _slugify(value):
|
|
113
|
+
"""'Acme Holdings, Ltd.' -> 'acme-holdings-ltd'. Returns '' for junk."""
|
|
114
|
+
if not value:
|
|
115
|
+
return ''
|
|
116
|
+
slug = re.sub(r'[^a-z0-9]+', '-', value.lower()).strip('-')
|
|
117
|
+
return slug
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _resolve_own_entity_ids(company):
|
|
121
|
+
"""Build the set of entity-index ids that denote THIS agent's employer.
|
|
122
|
+
|
|
123
|
+
Precedence:
|
|
124
|
+
1. an explicit `own_entity_ids` list in config/company.json (the escape
|
|
125
|
+
hatch for a group with entity ids that do not derive from its name);
|
|
126
|
+
2. otherwise, ids derived from name / legalName / domain.
|
|
127
|
+
|
|
128
|
+
Returns (ids, prefixes). `prefixes` lets a group's subsidiaries
|
|
129
|
+
('company:acme-holdings', 'company:acme-am-uk') resolve without every entity
|
|
130
|
+
being enumerated — the same reach the hardcoded prefix test had, but keyed
|
|
131
|
+
to the real employer.
|
|
132
|
+
"""
|
|
133
|
+
explicit = company.get('own_entity_ids')
|
|
134
|
+
if isinstance(explicit, list):
|
|
135
|
+
ids = {str(x).strip() for x in explicit if isinstance(x, (str,)) and str(x).strip()}
|
|
136
|
+
if ids:
|
|
137
|
+
# An explicit list is exhaustive by construction — no prefix reach.
|
|
138
|
+
return ids, set()
|
|
139
|
+
|
|
140
|
+
stems = set()
|
|
141
|
+
for raw in (COMPANY_NAME, COMPANY_LEGAL_NAME):
|
|
142
|
+
slug = _slugify(raw)
|
|
143
|
+
if slug:
|
|
144
|
+
stems.add(slug)
|
|
145
|
+
if COMPANY_DOMAIN:
|
|
146
|
+
# 'acme.com' / 'www.acme.co.uk' -> 'acme'
|
|
147
|
+
host = COMPANY_DOMAIN.lower().strip().split('/')[0]
|
|
148
|
+
host = host[4:] if host.startswith('www.') else host
|
|
149
|
+
label = host.split('.')[0]
|
|
150
|
+
slug = _slugify(label)
|
|
151
|
+
if slug:
|
|
152
|
+
stems.add(slug)
|
|
153
|
+
|
|
154
|
+
ids = {'company:' + s for s in stems}
|
|
155
|
+
# Every stem is also a prefix, so a group's subsidiaries resolve without
|
|
156
|
+
# each entity being enumerated ('acme' matches 'company:acme-holdings',
|
|
157
|
+
# 'company:acme-am-uk'). This is the same reach the old hardcoded
|
|
158
|
+
# `startswith('company:<example>')` test had, keyed to the real employer
|
|
159
|
+
# instead. An operator who needs it tighter sets `own_entity_ids` in
|
|
160
|
+
# config/company.json, which is exhaustive and disables prefix reach.
|
|
161
|
+
prefixes = {'company:' + s for s in stems}
|
|
162
|
+
return ids, prefixes
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
OWN_ENTITY_IDS, OWN_ENTITY_PREFIXES = _resolve_own_entity_ids(_COMPANY)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def is_own_entity(obj):
|
|
169
|
+
"""True when an entity-index object id denotes the agent's own employer.
|
|
170
|
+
|
|
171
|
+
Returns False for everything when no employer is configured — see FAIL
|
|
172
|
+
CLOSED above.
|
|
173
|
+
"""
|
|
174
|
+
if not obj or not isinstance(obj, str):
|
|
175
|
+
return False
|
|
176
|
+
if obj in OWN_ENTITY_IDS:
|
|
177
|
+
return True
|
|
178
|
+
return any(obj.startswith(p) for p in OWN_ENTITY_PREFIXES)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def legacy_relationship_value(data):
|
|
182
|
+
"""Read a profile's flat "relationship to the employer" field.
|
|
183
|
+
|
|
184
|
+
The canonical key is `relationship_to_company`. Profiles written before this
|
|
185
|
+
was de-hardcoded used `relationship_to_<employer-name>`, so rather than
|
|
186
|
+
naming any one company here we accept ANY `relationship_to_*` key. That
|
|
187
|
+
resolves existing memory/profiles/ without pinning a company name into the
|
|
188
|
+
reader — a hardcoded legacy key would only work for the one deployment whose
|
|
189
|
+
name it happened to be.
|
|
190
|
+
|
|
191
|
+
Returns '' when nothing usable is present.
|
|
192
|
+
"""
|
|
193
|
+
if not isinstance(data, dict):
|
|
194
|
+
return ''
|
|
195
|
+
primary = data.get('relationship_to_company')
|
|
196
|
+
if isinstance(primary, str) and primary.strip():
|
|
197
|
+
return primary
|
|
198
|
+
for key in sorted(data.keys()):
|
|
199
|
+
if not isinstance(key, str) or not key.startswith('relationship_to_'):
|
|
200
|
+
continue
|
|
201
|
+
value = data.get(key)
|
|
202
|
+
if isinstance(value, str) and value.strip():
|
|
203
|
+
return value
|
|
204
|
+
return ''
|
|
205
|
+
|
|
206
|
+
|
|
61
207
|
# Data source paths
|
|
62
208
|
CONTACTS_PATH = REPO_ROOT / "config" / "contacts.yaml"
|
|
63
209
|
ORG_CHART_PATH = REPO_ROOT / "knowledge" / "entities" / "org-chart.md"
|
|
@@ -384,9 +530,9 @@ def lookup_entity(name, entity_index=None):
|
|
|
384
530
|
Returns:
|
|
385
531
|
dict with keys:
|
|
386
532
|
- 'entity': the entity dict (or None)
|
|
387
|
-
- '
|
|
388
|
-
this entity's relationship to
|
|
389
|
-
- 'is_internal': bool — True if entity is employed by
|
|
533
|
+
- 'relationships_to_company': list of relationship dicts describing
|
|
534
|
+
this entity's relationship to the agent's employer / its entities
|
|
535
|
+
- 'is_internal': bool — True if entity is employed by one of those entities
|
|
390
536
|
- 'relationship_type': str — summary classification
|
|
391
537
|
('internal', 'vendor_candidate', 'external_vendor', 'external_advisor',
|
|
392
538
|
'investor', 'regulator', 'external', 'unknown')
|
|
@@ -423,19 +569,9 @@ def lookup_entity(name, entity_index=None):
|
|
|
423
569
|
# Collect relationships where this entity is the subject
|
|
424
570
|
subject_rels = rels_by_subject.get(entity_id, [])
|
|
425
571
|
|
|
426
|
-
# Determine relationship to
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
'company:northwind-am-difc', 'company:northwind-technologies',
|
|
430
|
-
'company:northwind-group-investments', 'company:northwind-investments',
|
|
431
|
-
'company:northwind-inc', 'company:northwind-ai-uk',
|
|
432
|
-
'company:northwind-am-uk', 'company:northwind-am-cayman',
|
|
433
|
-
'company:northwind-am-asia', 'company:northwind-asia',
|
|
434
|
-
'company:northwind-am-eu', 'company:northwind-am-us',
|
|
435
|
-
'company:northwind-am-ksa', 'company:northwind-ksa',
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
rels_to_northwind = []
|
|
572
|
+
# Determine relationship to the agent's own employer (resolved from
|
|
573
|
+
# config/company.json at load — see OWN_ENTITY_IDS above).
|
|
574
|
+
rels_to_company = []
|
|
439
575
|
is_internal = False
|
|
440
576
|
rel_type = 'unknown'
|
|
441
577
|
|
|
@@ -443,8 +579,8 @@ def lookup_entity(name, entity_index=None):
|
|
|
443
579
|
obj = rel.get('object', '')
|
|
444
580
|
pred = rel.get('predicate', '')
|
|
445
581
|
|
|
446
|
-
if obj
|
|
447
|
-
|
|
582
|
+
if is_own_entity(obj):
|
|
583
|
+
rels_to_company.append(rel)
|
|
448
584
|
|
|
449
585
|
if pred in ('employed_by', 'directs'):
|
|
450
586
|
is_internal = True
|
|
@@ -460,7 +596,7 @@ def lookup_entity(name, entity_index=None):
|
|
|
460
596
|
elif pred == 'regulates' and rel_type not in ('internal',):
|
|
461
597
|
rel_type = 'regulator'
|
|
462
598
|
|
|
463
|
-
# Check if entity is employed by
|
|
599
|
+
# Check if entity is employed by some OTHER company (external person)
|
|
464
600
|
if rel_type == 'unknown':
|
|
465
601
|
for rel in subject_rels:
|
|
466
602
|
pred = rel.get('predicate', '')
|
|
@@ -470,7 +606,7 @@ def lookup_entity(name, entity_index=None):
|
|
|
470
606
|
|
|
471
607
|
return {
|
|
472
608
|
'entity': entity,
|
|
473
|
-
'
|
|
609
|
+
'relationships_to_company': rels_to_company,
|
|
474
610
|
'is_internal': is_internal,
|
|
475
611
|
'relationship_type': rel_type,
|
|
476
612
|
}
|
|
@@ -549,10 +685,12 @@ def load_user_profiles():
|
|
|
549
685
|
# Company: identity.company or top-level
|
|
550
686
|
company = _nested_get(data, 'identity', 'company') or data.get('company', '')
|
|
551
687
|
|
|
552
|
-
# Relationship type: relationship.type,
|
|
688
|
+
# Relationship type: relationship.type, then the flat
|
|
689
|
+
# relationship_to_* key (see legacy_relationship_value), then
|
|
690
|
+
# top-level type.
|
|
553
691
|
rel_type = (
|
|
554
692
|
_nested_get(data, 'relationship', 'type')
|
|
555
|
-
or data
|
|
693
|
+
or legacy_relationship_value(data)
|
|
556
694
|
or data.get('type', '')
|
|
557
695
|
)
|
|
558
696
|
|
|
@@ -659,9 +797,22 @@ def check_relationship_claims(message):
|
|
|
659
797
|
'investor', 'regulator', 'external', 'unknown',
|
|
660
798
|
}
|
|
661
799
|
|
|
662
|
-
# Relationship claim patterns
|
|
800
|
+
# Relationship claim patterns.
|
|
801
|
+
# The possessive alternation is built from the RESOLVED employer name
|
|
802
|
+
# (config/company.json) rather than a hardcoded one — "Acme's engineer" is
|
|
803
|
+
# the same claim as "our engineer" and must be checked the same way. When no
|
|
804
|
+
# employer is configured the company alternative is simply absent and the
|
|
805
|
+
# generic possessives still apply.
|
|
806
|
+
possessive_alternatives = ['our', 'my', r'the company\'s', r'the team\'s']
|
|
807
|
+
for _name in (COMPANY_NAME, COMPANY_LEGAL_NAME):
|
|
808
|
+
if _name:
|
|
809
|
+
frag = re.escape(_name) + r'(?:\'s|’s)'
|
|
810
|
+
if frag not in possessive_alternatives:
|
|
811
|
+
possessive_alternatives.append(frag)
|
|
812
|
+
possessive_group = '(' + '|'.join(possessive_alternatives) + ')'
|
|
813
|
+
|
|
663
814
|
claim_patterns = [
|
|
664
|
-
(
|
|
815
|
+
(possessive_group + r'\s+'
|
|
665
816
|
r'(project manager|PM|consultant|advisor|counsel|lawyer|developer|'
|
|
666
817
|
r'engineer|designer|analyst|hire|employee|team member|colleague|'
|
|
667
818
|
r'staff member|contractor|associate|partner|director|officer|'
|
|
@@ -925,20 +1076,33 @@ def check_title_accuracy(message):
|
|
|
925
1076
|
def check_ai_self_disclosure(message):
|
|
926
1077
|
"""Scan for language that reveals the agent is AI.
|
|
927
1078
|
|
|
928
|
-
Must NOT flag general business discussion of
|
|
1079
|
+
Must NOT flag general business discussion of the company's OWN AI products
|
|
1080
|
+
("Acme AI", "acme.ai") — those exceptions are built from the resolved
|
|
1081
|
+
employer name, not a hardcoded one. With no employer configured the
|
|
1082
|
+
company-specific exceptions are simply absent, which errs toward FLAGGING.
|
|
929
1083
|
Returns list of issue dicts.
|
|
930
1084
|
"""
|
|
931
1085
|
issues = []
|
|
932
1086
|
|
|
933
1087
|
# Business context exceptions — if these appear, the word "AI" is OK
|
|
934
1088
|
business_exceptions = [
|
|
935
|
-
r'AI[- ]Born', r'AI[- ]native',
|
|
1089
|
+
r'AI[- ]Born', r'AI[- ]native',
|
|
936
1090
|
r'artificial intelligence\s+strategy', r'AI\s+products?',
|
|
937
1091
|
r'AI\s+platform', r'AI\s+capabilit', r'machine learning\s+models?',
|
|
938
1092
|
r'AI[- ]driven', r'AI\s+solutions?', r'AI\s+technolog',
|
|
939
1093
|
r'AI\s+invest', r'AI\s+fund', r'AI\s+sector', r'AI\s+market',
|
|
940
1094
|
r'AI\s+infrastructure', r'AI\s+research', r'generative\s+AI',
|
|
941
1095
|
]
|
|
1096
|
+
# "<Employer> AI" / "<employer>.ai" — the company's own product naming.
|
|
1097
|
+
for _name in (COMPANY_NAME, COMPANY_LEGAL_NAME):
|
|
1098
|
+
if _name:
|
|
1099
|
+
business_exceptions.append(re.escape(_name) + r'\s*AI')
|
|
1100
|
+
if COMPANY_DOMAIN:
|
|
1101
|
+
_host = COMPANY_DOMAIN.lower().strip().split('/')[0]
|
|
1102
|
+
_host = _host[4:] if _host.startswith('www.') else _host
|
|
1103
|
+
_label = _host.split('.')[0]
|
|
1104
|
+
if _label:
|
|
1105
|
+
business_exceptions.append(re.escape(_label) + r'\.ai')
|
|
942
1106
|
|
|
943
1107
|
# Self-referential AI disclosure patterns
|
|
944
1108
|
disclosure_patterns = [
|