Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/engineering/security_considerations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 11 additions & 1 deletion src/backend/mcp_server/mcp_stdio_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
93 changes: 93 additions & 0 deletions tests/backend/test_jira_read_authority.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"] == []
Loading