fix(session): require config-backed mutation authority and workspace-bound targets (#714)

Close the #714 remediation gap left at 943d402 where env-only mutation
profiles, REMOTES Timesheet defaults treated as explicit caller input, and
machine-dependent Git remotes produced false greens.

- Fail closed when mutations lack a config-backed profile with non-empty
  allowed_repositories; env ops cannot invent mutation authority.
- Preserve omitted-vs-explicit org/repo provenance through the mutation
  gate; omitted targets bind to the verified workspace repository.
- Prefer workspace-aligned Git remotes over historical Timesheet defaults
  in MCP _resolve and CLI resolve_remote.
- Centralize deterministic config-backed mutation fixtures; migrate
  mutation tests off env-only authority without weakening security
  assertions.

Full suite: 2744 passed, 6 skipped.
This commit is contained in:
2026-07-15 21:13:21 -04:00
parent 943d40270e
commit dc899d23c8
36 changed files with 1343 additions and 227 deletions
+268 -40
View File
@@ -1140,24 +1140,50 @@ def _workspace_repository_slug(remote: str | None) -> str | None:
return session_ctx.format_repository_slug(parsed[0], parsed[1])
def _trusted_session_repository(profile: dict, remote: str | None) -> dict:
def _trusted_session_repository(
profile: dict,
remote: str | None,
*,
for_mutation: bool = False,
) -> dict:
"""Resolve and authorize the session repository from the verified workspace.
Returns ``org`` / ``repository`` / ``reasons``. A profile's
``allowed_repositories`` is an authorization boundary: the verified
workspace selects exactly one entry from it, and the session never binds to
the list itself nor switches between entries.
*for_mutation*: require a non-empty configured allowlist and a complete
workspace-derived identity (fail closed; no self-authorization).
"""
reasons: list[str] = []
try:
allowed = session_ctx.declared_allowed_repositories(
profile, strict=for_mutation
)
except ValueError as exc:
return {
"org": None,
"repository": None,
"reasons": [str(exc)],
}
slug = _workspace_repository_slug(remote)
scope = session_ctx.assess_repository_scope(
workspace_slug=slug,
allowed=session_ctx.declared_allowed_repositories(profile),
allowed=allowed,
profile_name=profile.get("profile_name"),
require_scope=for_mutation,
)
if scope.get("block"):
return {"org": None, "repository": None, "reasons": list(scope["reasons"])}
parts = session_ctx.parse_repository_slug(slug) if slug else None
if not parts:
if for_mutation:
reasons.append(
"mutation denied: no verified workspace repository identity "
"could be established (fail closed)"
)
return {"org": None, "repository": None, "reasons": reasons}
return {"org": None, "repository": None, "reasons": []}
return {"org": parts[0], "repository": parts[1], "reasons": []}
@@ -1774,20 +1800,45 @@ def _effective_remote(remote: str) -> str:
def _resolve(remote: str, host: str | None, org: str | None, repo: str | None):
"""Resolve remote + overrides to (host, org, repo)."""
"""Resolve remote + overrides to (host, org, repo).
#714 / #530: 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
by the remote/repo guard. Provenance of omitted-vs-explicit is preserved
at mutation gates by passing the original caller values (not these
resolved defaults) into override checks.
"""
remote = _effective_remote(remote)
if remote not in REMOTES:
raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")
profile = REMOTES[remote]
org_explicit = org is not None
repo_explicit = repo is not None
resolved_host = host or profile["host"]
resolved_org = org or profile["org"]
resolved_repo = repo or profile["repo"]
resolved_org = org if org_explicit else profile["org"]
resolved_repo = repo if repo_explicit else profile["repo"]
filled_org = False
filled_repo = False
if not org_explicit or not repo_explicit:
parsed = remote_repo_guard.parse_org_repo_from_remote_url(
_local_git_remote_url(remote)
)
if parsed:
if not org_explicit:
resolved_org = parsed[0]
filled_org = True
if not repo_explicit:
resolved_repo = parsed[1]
filled_repo = True
_enforce_remote_repo_guard(
remote,
resolved_org,
resolved_repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
# Workspace-filled sides are intentional alignment for #530.
org_explicit=org_explicit or filled_org,
repo_explicit=repo_explicit or filled_repo,
)
return (resolved_host, resolved_org, resolved_repo)
@@ -2145,6 +2196,8 @@ def gitea_create_issue(
host=h,
org=o,
repo=r,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
@@ -2321,7 +2374,15 @@ def gitea_lock_issue(
)
blocked = _profile_permission_block(
task_capability_map.required_permission("lock_issue"))
task_capability_map.required_permission("lock_issue"),
issue_number=issue_number,
remote=remote,
host=host,
org=org,
repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
@@ -2589,6 +2650,8 @@ def gitea_create_pr(
host=host,
org=org,
repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
@@ -5426,6 +5489,8 @@ def gitea_commit_files(
blocked = _profile_permission_block(
task_capability_map.required_permission("commit_files"),
commit="", branch="", remote=remote, host=host, org=org, repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
@@ -7343,7 +7408,15 @@ def gitea_close_issue(
dict with 'success' boolean and 'message'.
"""
blocked = _profile_permission_block(
task_capability_map.required_permission("close_issue"))
task_capability_map.required_permission("close_issue"),
issue_number=issue_number,
remote=remote,
host=host,
org=org,
repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
verify_preflight_purity(remote, task="close_issue")
@@ -7641,6 +7714,81 @@ def _profile_operation_gate(op: str) -> list[str]:
return [f"profile is not allowed to {op}"]
def _mutation_config_authority_block(required_operation: str) -> dict | None:
"""#714: mutations require config-backed profile + non-empty repository scope.
Environment-only profiles may support read-only diagnostics but cannot
authorize writes. Env vars may narrow operations on a configured profile
but cannot invent mutation authority or repository scope.
"""
if required_operation == "gitea.read":
return None
try:
profile = get_profile()
except Exception as exc:
return {
"success": False,
"performed": False,
"reasons": [
f"profile could not be resolved (fail closed): {_redact(str(exc))}"
],
"blocker_kind": "mutation_authority",
}
config = None
try:
config = gitea_config.load_config()
except Exception as exc:
return {
"success": False,
"performed": False,
"reasons": [
f"profiles configuration unreadable (fail closed): {_redact(str(exc))}"
],
"blocker_kind": "mutation_authority",
}
if not config:
return {
"success": False,
"performed": False,
"reasons": [
"mutation denied: env-only / unconfigured profiles cannot "
"authorize mutations; provision GITEA_MCP_CONFIG with a v2 "
"profile that declares non-empty allowed_repositories "
"(fail closed)"
],
"blocker_kind": "mutation_authority",
"exact_next_action": (
"BLOCKED + DIAGNOSE: configure a trusted v2 profile with "
"allowed_repositories and relaunch; do not widen scope via env."
),
}
try:
allowed = session_ctx.declared_allowed_repositories(profile, strict=True)
except ValueError as exc:
return {
"success": False,
"performed": False,
"reasons": [str(exc)],
"blocker_kind": "mutation_authority",
}
if not allowed:
return {
"success": False,
"performed": False,
"reasons": [
f"mutation denied: profile '{profile.get('profile_name')}' "
"must declare a non-empty allowed_repositories list "
"(fail closed)"
],
"blocker_kind": "mutation_authority",
"exact_next_action": (
"BLOCKED + DIAGNOSE: add allowed_repositories: "
"[\"Owner/Repo\"] to the active v2 profile and restart."
),
}
return None
def _session_context_mutation_block(
*,
remote: str | None,
@@ -7665,19 +7813,23 @@ def _session_context_mutation_block(
}
profile_name = profile.get("profile_name")
expected = (profile.get("username") or "").strip() or None
# Infer remote from profile host when tools omit it (legacy call sites).
if not remote:
p_host = session_ctx.profile_host(profile)
if p_host:
for rname, rcfg in REMOTES.items():
if (rcfg.get("host") or "").lower() == p_host:
remote = rname
break
remote_config = REMOTES.get(remote or "", {}) if remote else {}
h = host or remote_config.get("host")
h = host or remote_config.get("host") or session_ctx.profile_host(profile)
# #714: repository identity comes from the verified workspace only. The
# caller-supplied org/repo are a *request target* to be checked against the
# binding — they must never establish, complete, or replace it.
trusted = _trusted_session_repository(profile, remote)
trusted = _trusted_session_repository(profile, remote, for_mutation=True)
resolved_org = trusted["org"]
resolved_repo = trusted["repository"]
identity = username
# Legacy env-only profiles do not declare an expected identity. Their
# first mutation still pins remote/host/repository, while existing audit
# paths resolve identity at the actual write. Configured identities are
# always verified here before a mutation can proceed.
if identity is None and expected and h:
try:
identity = _authenticated_username(h)
@@ -7699,18 +7851,16 @@ def _session_context_mutation_block(
authenticated=identity, expected_username=expected
)
reasons.extend(id_gate.get("reasons") or [])
# Repository scope denial (unauthorized workspace) fails closed here.
reasons.extend(trusted.get("reasons") or [])
# Seed if unbound so subsequent tools share one context; then assess.
_seed_session_context(
profile=profile,
remote=remote,
host=h,
identity=identity,
source="mutation-gate-seed",
)
if trusted.get("repository") and trusted.get("org"):
_seed_session_context(
profile=profile,
remote=remote,
host=h,
identity=identity,
source="mutation-gate-seed",
)
ctx_gate = session_ctx.assess_session_context(
profile_name=profile_name,
remote=remote,
@@ -7720,19 +7870,37 @@ def _session_context_mutation_block(
org=resolved_org,
expected_username=expected,
require_bound=True,
require_complete=bool(
session_ctx.declared_allowed_repositories(profile)
),
require_complete=True,
)
reasons.extend(ctx_gate.get("reasons") or [])
# The request may only be checked against the immutable binding (#714).
# Provenance: only explicit caller org/repo may override the binding.
# Tools must pass org_explicit/repo_explicit (True when the caller supplied
# the parameter). When flags are absent (legacy), strip pure REMOTES
# defaults that disagree with the binding so omitted targets use it.
bound_ctx = session_ctx.get_session_context() or {}
bound_org = bound_ctx.get("org") or resolved_org
bound_repo = bound_ctx.get("repository") or resolved_repo
org_explicit = extra_fields.get("org_explicit")
repo_explicit = extra_fields.get("repo_explicit")
req_org = (org or "").strip() or None
req_repo = (repo or "").strip() or None
remotes_entry = REMOTES.get(remote or "", {}) if remote else {}
default_org = (remotes_entry.get("org") or "").strip() or None
default_repo = (remotes_entry.get("repo") or "").strip() or None
if org_explicit is False:
req_org = None
elif org_explicit is None and req_org and default_org and req_org.lower() == default_org.lower() and bound_org and req_org.lower() != bound_org.lower():
req_org = None
if repo_explicit is False:
req_repo = None
elif repo_explicit is None and req_repo and default_repo and req_repo.lower() == default_repo.lower() and bound_repo and req_repo.lower() != bound_repo.lower():
req_repo = None
override_gate = session_ctx.assess_repository_override(
requested_org=org,
requested_repo=repo,
bound_org=bound_ctx.get("org"),
bound_repo=bound_ctx.get("repository"),
requested_org=req_org,
requested_repo=req_repo,
bound_org=bound_org,
bound_repo=bound_repo,
)
reasons.extend(override_gate.get("reasons") or [])
# De-dupe while preserving order
@@ -7786,6 +7954,13 @@ def _profile_permission_block(required_operation: str, **extra_fields) -> dict |
blocked.update(extra_fields)
return blocked
auth_block = _mutation_config_authority_block(required_operation)
if auth_block is not None:
for k in ("number", "issue_number", "pr_number", "remote", "host", "org", "repo"):
if k in extra_fields:
auth_block[k] = extra_fields[k]
return auth_block
return _session_context_mutation_block(
remote=extra_fields.get("remote"),
host=extra_fields.get("host"),
@@ -7794,6 +7969,8 @@ def _profile_permission_block(required_operation: str, **extra_fields) -> dict |
number=extra_fields.get("number"),
issue_number=extra_fields.get("issue_number"),
pr_number=extra_fields.get("pr_number"),
org_explicit=extra_fields.get("org_explicit"),
repo_explicit=extra_fields.get("repo_explicit"),
)
@@ -10902,7 +11079,7 @@ def gitea_activate_profile(
# Activation is the sanctioned logical-session boundary, so it establishes
# the complete binding — including the workspace-verified repository. An
# unauthorized workspace fails closed here rather than at first mutation.
activated_trusted = _trusted_session_repository(after_profile_data, remote)
activated_trusted = _trusted_session_repository(after_profile_data, remote, for_mutation=True)
if activated_trusted.get("reasons"):
return {
"success": False,
@@ -11087,7 +11264,15 @@ def gitea_mark_issue(
raise ValueError(f"action must be 'start' or 'done', got '{action}'")
blocked = _profile_permission_block(
task_capability_map.required_permission("mark_issue"))
task_capability_map.required_permission("mark_issue"),
issue_number=issue_number,
remote=remote,
host=host,
org=org,
repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
verify_preflight_purity(remote, worktree_path=worktree_path, task="mark_issue")
@@ -11169,7 +11354,15 @@ def gitea_post_heartbeat(
) -> dict:
"""Post a structured progress heartbeat on a claimed issue (#268)."""
blocked = _profile_permission_block(
task_capability_map.required_permission("post_heartbeat"))
task_capability_map.required_permission("post_heartbeat"),
issue_number=issue_number,
remote=remote,
host=host,
org=org,
repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
verify_preflight_purity(remote, task="post_heartbeat")
@@ -11208,7 +11401,14 @@ def gitea_acquire_conflict_fix_lease(
) -> dict:
"""Acquire a conflict-fix lease on a PR branch before pushing (#399)."""
blocked = _profile_permission_block(
task_capability_map.required_permission("comment_issue"))
task_capability_map.required_permission("comment_issue"),
remote=remote,
host=host,
org=org,
repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
verify_preflight_purity(remote, worktree_path=worktree_path)
@@ -11463,7 +11663,14 @@ def gitea_cleanup_stale_claims(
)
blocked = _profile_permission_block(
task_capability_map.required_permission("cleanup_stale_claims"))
task_capability_map.required_permission("cleanup_stale_claims"),
remote=remote,
host=host,
org=org,
repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
verify_preflight_purity(remote, task="cleanup_stale_claims")
@@ -11576,7 +11783,14 @@ def gitea_create_label(
dict containing the created label details.
"""
blocked = _profile_permission_block(
task_capability_map.required_permission("create_label"))
task_capability_map.required_permission("create_label"),
remote=remote,
host=host,
org=org,
repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_label")
@@ -11622,7 +11836,15 @@ def gitea_set_issue_labels(
list of all labels currently applied to the issue.
"""
blocked = _profile_permission_block(
task_capability_map.required_permission("set_issue_labels"))
task_capability_map.required_permission("set_issue_labels"),
issue_number=issue_number,
remote=remote,
host=host,
org=org,
repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
verify_preflight_purity(remote, worktree_path=worktree_path, task="set_issue_labels")
@@ -12587,6 +12809,12 @@ def gitea_observability_reconcile_incident(
create_block = _profile_permission_block(
task_capability_map.required_permission("create_issue"),
number=None,
remote=remote,
host=host,
org=org,
repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if create_block:
return {