feat(mcp): enforce strict cross-project mutation boundaries (Closes #707)
This commit is contained in:
+28
-11
@@ -3325,10 +3325,16 @@ def _effective_remote(remote: str) -> str:
|
||||
return remote
|
||||
|
||||
|
||||
def _resolve(remote: str, host: str | None, org: str | None, repo: str | None):
|
||||
def _resolve(
|
||||
remote: str,
|
||||
host: str | None,
|
||||
org: str | None,
|
||||
repo: str | None,
|
||||
for_mutation: bool = False,
|
||||
):
|
||||
"""Resolve remote + overrides to (host, org, repo).
|
||||
|
||||
#714 / #530: when the caller omits org and/or repo, prefer the
|
||||
#714 / #530 / #707: when the caller omits org and/or repo, prefer the
|
||||
workspace-aligned git remote over ``REMOTES`` defaults (e.g. bare
|
||||
``remote=prgs`` must not force ``Timesheet`` when the checkout is
|
||||
``Gitea-Tools``). Explicit caller org/repo always win and are validated
|
||||
@@ -3414,6 +3420,7 @@ def _resolve(remote: str, host: str | None, org: str | None, repo: str | None):
|
||||
# Workspace-filled sides are intentional alignment for #530.
|
||||
org_explicit=org_explicit or filled_org,
|
||||
repo_explicit=repo_explicit or filled_repo,
|
||||
for_mutation=for_mutation,
|
||||
)
|
||||
return (resolved_host, resolved_org, resolved_repo)
|
||||
|
||||
@@ -3475,8 +3482,9 @@ def _enforce_remote_repo_guard(
|
||||
*,
|
||||
org_explicit: bool,
|
||||
repo_explicit: bool,
|
||||
for_mutation: bool = False,
|
||||
) -> None:
|
||||
"""Fail closed on a remote/repo mismatch vs. the local git remote (#530).
|
||||
"""Fail closed on a remote/repo mismatch vs. the local git remote (#530/#707).
|
||||
|
||||
Best-effort: bypassed under pytest unless ``GITEA_FORCE_REMOTE_REPO_CHECK`` is
|
||||
set, so the unit suite (which calls tools with bare remotes against mocked APIs)
|
||||
@@ -3488,6 +3496,12 @@ def _enforce_remote_repo_guard(
|
||||
):
|
||||
return
|
||||
local_remote_url = _local_git_remote_url(remote)
|
||||
primary_org = None
|
||||
primary_repo = None
|
||||
ctx = session_ctx.get_session_context()
|
||||
if ctx:
|
||||
primary_org = ctx.get("org")
|
||||
primary_repo = ctx.get("repository")
|
||||
assessment = remote_repo_guard.assess_remote_repo_match(
|
||||
remote=remote,
|
||||
resolved_org=resolved_org,
|
||||
@@ -3495,6 +3509,9 @@ def _enforce_remote_repo_guard(
|
||||
local_remote_url=local_remote_url,
|
||||
org_explicit=org_explicit,
|
||||
repo_explicit=repo_explicit,
|
||||
for_mutation=for_mutation,
|
||||
primary_org=primary_org,
|
||||
primary_repo=primary_repo,
|
||||
)
|
||||
if assessment["block"]:
|
||||
raise RuntimeError(remote_repo_guard.format_remote_repo_guard_error(assessment))
|
||||
@@ -4063,7 +4080,7 @@ def gitea_lock_issue(
|
||||
resolved_worktree = issue_lock_worktree.resolve_author_worktree_path(
|
||||
worktree_path, _canonical_local_git_root()
|
||||
)
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
h, o, r = _resolve(remote, host, org, repo, for_mutation=True)
|
||||
existing_issue_lock = _load_existing_issue_lock(
|
||||
remote=remote, org=o, repo=r, issue_number=issue_number
|
||||
)
|
||||
@@ -5239,7 +5256,7 @@ def gitea_create_pr(
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
h, o, r = _resolve(remote, host, org, repo, for_mutation=True)
|
||||
|
||||
# ── Issue Lock Validation (Issue #194 / #196 / #443) ──
|
||||
lock_data = _resolve_issue_lock_for_pr(remote=remote, org=o, repo=r, head=head)
|
||||
@@ -9560,7 +9577,7 @@ def gitea_edit_pr(
|
||||
required_permission="gitea.pr.close",
|
||||
)
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
h, o, r = _resolve(remote, host, org, repo, for_mutation=True)
|
||||
auth = _auth(h)
|
||||
url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}"
|
||||
|
||||
@@ -9844,7 +9861,7 @@ def gitea_commit_files(
|
||||
verify_preflight_purity(remote=remote, worktree_path=worktree_path, task="commit_files", org=org, repo=repo)
|
||||
processed_files, source_proofs = _prepare_commit_payload_files(files)
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
h, o, r = _resolve(remote, host, org, repo, for_mutation=True)
|
||||
auth = _auth(h)
|
||||
url = f"{repo_api_url(h, o, r)}/contents"
|
||||
|
||||
@@ -9980,7 +9997,7 @@ def gitea_publish_unpublished_issue_branch(
|
||||
repo=repo,
|
||||
)
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
h, o, r = _resolve(remote, host, org, repo, for_mutation=True)
|
||||
git_remote = (git_remote_name or remote or "").strip()
|
||||
|
||||
existing_lock = issue_lock_store.load_issue_lock(
|
||||
@@ -10183,7 +10200,7 @@ def gitea_bootstrap_author_issue_worktree(
|
||||
repo=repo,
|
||||
)
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
h, o, r = _resolve(remote, host, org, repo, for_mutation=True)
|
||||
canonical_root = _canonical_local_git_root()
|
||||
|
||||
import author_issue_bootstrap
|
||||
@@ -12131,7 +12148,7 @@ def gitea_reconcile_merged_cleanups(
|
||||
"blocker_kind": "invalid_pr_number",
|
||||
}
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
h, o, r = _resolve(remote, host, org, repo, for_mutation=True)
|
||||
auth = _auth(h)
|
||||
base = repo_api_url(h, o, r)
|
||||
|
||||
@@ -12521,7 +12538,7 @@ def gitea_assess_already_landed_reconciliation(
|
||||
"permission_report": _permission_block_report("gitea.read"),
|
||||
}
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
h, o, r = _resolve(remote, host, org, repo, for_mutation=True)
|
||||
auth = _auth(h)
|
||||
pr = api_request("GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user