diff --git a/docs/engineering/security_considerations.md b/docs/engineering/security_considerations.md index 44b95f871..624452d3c 100644 --- a/docs/engineering/security_considerations.md +++ b/docs/engineering/security_considerations.md @@ -335,6 +335,12 @@ direct workflow calls; a model-supplied selector or identity is not authority. The account's Jira permissions determine its issue visibility. This bounded owner path does not delegate the deployment account to other Von users or release Jira writes. Explicit trusted-local operator access remains separate. +The local stdio `jira_create_issue` entry point preserves that operator +provenance through its nested issue-type preflight; standalone +`jira_get_project_issue_types` uses the same boundary. Existing actor or +untrusted invocation context is never promoted to operator. Creation still +requires the coding-agent write gate, project allow-list and explicit write +intent. This does not grant Jira creation to ordinary browser turns. Missing or ambiguous identity binding fails closed, and a changed credential configuration cannot silently reuse the previous account's proxy. diff --git a/src/backend/mcp_server/mcp_stdio_server.py b/src/backend/mcp_server/mcp_stdio_server.py index 13ce7be9c..2ac388dd1 100644 --- a/src/backend/mcp_server/mcp_stdio_server.py +++ b/src/backend/mcp_server/mcp_stdio_server.py @@ -1052,6 +1052,13 @@ async def list_tools() -> list[Tool]: } ) +# Creation already has project and explicit-write-intent guardrails. Its +# nested metadata read must carry the same entry-point provenance as search. +# This is not a browser capability grant or a payload-selectable operator flag. +_TRUSTED_LOCAL_OPERATOR_JIRA_CREATE_TOOLS = frozenset( + {"jira_create_issue", "jira_get_project_issue_types"} +) + _STDIO_GOVERNED_ONTOLOGY_METHODS = { "create_concepts": "create_concepts", "upsert_text_relation": "upsert_text_relation", @@ -1224,7 +1231,10 @@ async def call_tool(name: str, arguments: Any) -> list[TextContent]: # type: ig } ) ] - if name in _TRUSTED_LOCAL_OPERATOR_DIAGNOSTIC_READ_TOOLS: + if name in ( + _TRUSTED_LOCAL_OPERATOR_DIAGNOSTIC_READ_TOOLS + | _TRUSTED_LOCAL_OPERATOR_JIRA_CREATE_TOOLS + ): from src.backend.security.access_control import ( get_effective_organisation_concept_id, get_effective_user_concept_id, diff --git a/tests/backend/test_jira_read_authority.py b/tests/backend/test_jira_read_authority.py index af0cb6b7d..3f920e5cb 100644 --- a/tests/backend/test_jira_read_authority.py +++ b/tests/backend/test_jira_read_authority.py @@ -53,6 +53,8 @@ def lookup(email): async def call(tool, arguments): state["calls"].append((tool, arguments)) + if tool == "jira_get_project_issue_types": + return {"success": True, "available_issue_type_names": ["Task"]} return {"issues": [], "key": "PROJ-1", "comments": []} monkeypatch.setattr(proxy._client, "call_tool", call) @@ -269,3 +271,94 @@ def test_local_stdio_jira_reads_reach_account_rpc(account, name, arguments): result = json.loads(blocks[0].text) assert result.get("success") is not False assert account["calls"] == [(name, arguments)] + + +def test_local_stdio_create_preserves_authority_through_preflight(account, monkeypatch): + import json + from src.backend.mcp_server import mcp_stdio_server as stdio + from src.backend.integrations.internal_mcp import catalogue + + monkeypatch.setattr(catalogue, "_jira_project_allow_list", lambda: ["PROJ"]) + result = json.loads( + asyncio.run( + stdio.call_tool( + "jira_create_issue", + { + "project_key": "PROJ", + "issue_type": "Task", + "summary": "Authority regression", + "approved": True, + "dry_run": False, + }, + ) + )[0].text + ) + assert result.get("success") is not False, result + assert [name for name, _ in account["calls"]] == [ + "jira_get_project_issue_types", + "jira_create_issue", + ] + + +@pytest.mark.parametrize("actor", [OTHER, None]) +def test_stdio_create_never_promotes_existing_untrusted_context( + account, monkeypatch, actor +): + import json + from src.backend.mcp_server import mcp_stdio_server as stdio + from src.backend.integrations.internal_mcp import catalogue + from src.backend.integrations.internal_mcp.gateway import ( + bind_internal_mcp_actor_context_source, + ) + + monkeypatch.setattr(catalogue, "_jira_project_allow_list", lambda: ["PROJ"]) + with ( + override_current_actor(actor, None), + bind_internal_mcp_actor_context_source("tool_payload_fallback"), + ): + result = json.loads( + asyncio.run( + stdio.call_tool( + "jira_create_issue", + { + "project_key": "PROJ", + "issue_type": "Task", + "summary": "Must fail", + "approved": True, + "dry_run": False, + "user_concept_id": OWNER, + }, + ) + )[0].text + ) + assert result["success"] is False + assert account["calls"] == [] + + +def test_local_stdio_create_still_requires_write_intent_and_project_allowlist( + account, monkeypatch +): + import json + from src.backend.mcp_server import mcp_stdio_server as stdio + from src.backend.integrations.internal_mcp import catalogue + + monkeypatch.setattr(catalogue, "_jira_project_allow_list", lambda: ["PROJ"]) + for fields, expected in [ + ({"project_key": "OTHER", "approved": True}, "project_not_allowlisted"), + ({"project_key": "PROJ", "approved": False}, "approval_required"), + ]: + result = json.loads( + asyncio.run( + stdio.call_tool( + "jira_create_issue", + { + "issue_type": "Task", + "summary": "Must fail", + "dry_run": False, + **fields, + }, + ) + )[0].text + ) + assert result["error_code"] == expected + assert account["calls"] == []