From cba686a6b967ad633a401c162d3eff8abc1dda7c Mon Sep 17 00:00:00 2001 From: Devika Verma Date: Wed, 19 Aug 2026 21:41:58 +0530 Subject: [PATCH] Gate contact-enrichment lookups as egress --- coworker/connectors/tool_defs.py | 12 +++---- coworker/risk.py | 19 ++++++++++- tests/test_egress_and_overrides.py | 51 ++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/coworker/connectors/tool_defs.py b/coworker/connectors/tool_defs.py index 88b7735a..d423e0b1 100644 --- a/coworker/connectors/tool_defs.py +++ b/coworker/connectors/tool_defs.py @@ -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( diff --git a/coworker/risk.py b/coworker/risk.py index 26faeac0..9c56d674 100644 --- a/coworker/risk.py +++ b/coworker/risk.py @@ -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}, diff --git a/tests/test_egress_and_overrides.py b/tests/test_egress_and_overrides.py index 04828715..971eb889 100644 --- a/tests/test_egress_and_overrides.py +++ b/tests/test_egress_and_overrides.py @@ -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