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:
+268
-40
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user