Gate contact-enrichment lookups as egress

This commit is contained in:
Devika Verma
2026-08-19 21:41:58 +05:30
parent 8e6a43f296
commit cba686a6b9
3 changed files with 75 additions and 7 deletions
+6 -6
View File
@@ -849,42 +849,42 @@ TOOL_DEFS: tuple[ConnectorToolDef, ...] = (
"apollo",
"apollo_enrich_person",
"Enrich person",
"read",
"write",
"Enrich a person by email or name.",
),
ConnectorToolDef(
"apollo",
"apollo_enrich_company",
"Enrich company",
"read",
"write",
"Enrich a company by domain.",
),
ConnectorToolDef(
"apollo",
"apollo_search_people",
"Search people",
"read",
"write",
"Keyword-search Apollo's B2B database.",
),
ConnectorToolDef(
"hunter",
"hunter_domain_search",
"Domain search",
"read",
"write",
"Find published emails for a domain.",
),
ConnectorToolDef(
"hunter",
"hunter_find_email",
"Find email",
"read",
"write",
"Find a person's likely email address.",
),
ConnectorToolDef(
"hunter",
"hunter_verify_email",
"Verify email",
"read",
"write",
"Check whether an email is deliverable.",
),
ConnectorToolDef(
+18 -1
View File
@@ -33,7 +33,24 @@ SHELL_TOOL = "run_shell"
# The browser connector's URL tools are the same channel by another name (OPE-111):
# classifying them here gives them the full egress treatment (domain allowlist, host-named
# cards) instead of a bare approval gate.
EGRESS_TOOLS = {"web_fetch", "web_search", "browser_read_url", "browser_open_url"}
# Contact-enrichment lookups are the `web_search` case with worse payloads: a fixed
# destination (Apollo/Hunter), a model-chosen query — except the query IS someone's name
# and email, and the someone is a third party who never agreed to it. Catalogued as reads
# they ran with no card at all, including in Discuss mode (OPE-117 review follow-up).
_ENRICHMENT_TOOLS = {
"apollo_enrich_person",
"apollo_enrich_company",
"apollo_search_people",
"hunter_domain_search",
"hunter_find_email",
"hunter_verify_email",
}
EGRESS_TOOLS = {
"web_fetch",
"web_search",
"browser_read_url",
"browser_open_url",
} | _ENRICHMENT_TOOLS
_BASE: dict[str, RiskClass] = {
**{name: RiskClass.WRITE_LOCAL for name in WRITE_TOOLS},
+51
View File
@@ -216,3 +216,54 @@ def test_patch_scoping_holds_in_auto_mode(tmp_path):
assert not eng.evaluate("apply_patch", {"patch": escape}, None).allowed
ok = "*** Begin Patch\n*** Update File: src/app.py\n@@\n-a\n+b\n*** End Patch"
assert eng.evaluate("apply_patch", {"patch": ok}, None).allowed
# -- contact enrichment is egress, not a read -----------------------------------
# Apollo/Hunter lookups send a real person's name and email to a third-party data broker
# to ask the question at all. That is the web_search shape — fixed destination, model-chosen
# query — except the query is somebody else's personal data, and they never agreed to it.
_ENRICHMENT = [
("apollo_enrich_person", {"email": "jane@acme.com", "name": "Jane Smith"}),
("apollo_enrich_company", {"domain": "acme.com"}),
("apollo_search_people", {"q": "VP Engineering fintech"}),
("hunter_domain_search", {"domain": "acme.com"}),
("hunter_find_email", {"domain": "acme.com", "first_name": "Jane", "last_name": "Smith"}),
("hunter_verify_email", {"email": "jane@acme.com"}),
]
@pytest.mark.parametrize("tool,args", _ENRICHMENT)
def test_enrichment_lookups_classify_as_egress(tool, args):
from coworker.connectors.tool_defs import approval_for_tool
assert classify(tool) is RiskClass.EGRESS, tool
# The catalog label agrees with the gate, so the UI and the engine cannot drift apart.
assert approval_for_tool(tool) is True, tool
@pytest.mark.parametrize("tool,args", _ENRICHMENT)
def test_enrichment_asks_before_sending_someone_elses_details(tmp_path, tool, args):
gate = PermissionEngine(workspace_root=tmp_path, mode=Mode.AUTO_APPROVE)
d = gate.evaluate(tool, args, None)
assert not d.allowed and d.needs_user
@pytest.mark.parametrize("mode", [Mode.DISCUSS, Mode.PLAN])
def test_enrichment_no_longer_runs_in_read_only_modes(tmp_path, mode):
# The bug underneath the policy question: catalogued as a read, a lookup would send a
# third party's name to a broker during a mode that exists to do nothing consequential.
gate = PermissionEngine(workspace_root=tmp_path, mode=mode)
d = gate.evaluate("hunter_find_email", {"domain": "acme.com", "first_name": "Jane"}, None)
assert not d.allowed and not d.needs_user
def test_an_override_cannot_quietly_make_enrichment_a_read_again(tmp_path):
ov = _override({"hunter_find_email": RiskClass.READ})
assert classify("hunter_find_email", None, ov) is RiskClass.EGRESS
def test_genuine_connector_reads_are_untouched():
# The floor is about data leaving on the way out, not about connectors in general:
# reading your own mailbox or calendar stays free.
for name in ("email_search", "gcal_list_events", "gmail_get_message", "github_get_issue"):
assert classify(name) is RiskClass.READ, name