Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b359e0c26 | ||
|
|
9cb12ee0f4 | ||
|
|
ec5cf67771 |
@@ -55,3 +55,12 @@ GITEA_MCP_PROFILE=prgs
|
|||||||
# GITEA_MERGER_WORKTREE=/path/to/repo/branches/merge-pr456
|
# GITEA_MERGER_WORKTREE=/path/to/repo/branches/merge-pr456
|
||||||
# GITEA_RECONCILER_WORKTREE=/path/to/repo/branches/reconcile-pr456
|
# GITEA_RECONCILER_WORKTREE=/path/to/repo/branches/reconcile-pr456
|
||||||
# GITEA_ACTIVE_WORKTREE=/path/to/repo/branches/session-override
|
# GITEA_ACTIVE_WORKTREE=/path/to/repo/branches/session-override
|
||||||
|
|
||||||
|
# Durable HMAC key for irrecoverable decision-lock provenance artifacts (#709 F4).
|
||||||
|
# REQUIRED in production native MCP processes that mint or verify recovery
|
||||||
|
# authorization (reconciler + merger must share the same key). Hex (preferred),
|
||||||
|
# base64, or utf-8 literal (>=16 bytes). Never generate an ephemeral per-process
|
||||||
|
# key — cross-process / post-restart verification would fail closed.
|
||||||
|
# GITEA_IRRECOVERABLE_AUTH_HMAC_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||||
|
# Optional key version string bound into the signature (for rotation).
|
||||||
|
# GITEA_IRRECOVERABLE_AUTH_HMAC_KEY_VERSION=v1
|
||||||
|
|||||||
+1498
-395
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+196
-1
@@ -36,7 +36,24 @@ KIND_REVIEW_DRAFT = "review_draft"
|
|||||||
# other session proofs; a contaminated session fails closed on gated mutations
|
# other session proofs; a contaminated session fails closed on gated mutations
|
||||||
# until a reconciler audits and clears it.
|
# until a reconciler audits and clears it.
|
||||||
KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination"
|
KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination"
|
||||||
|
# #709: archive prior terminal decision ledgers instead of silent overwrite.
|
||||||
|
KIND_DECISION_LOCK_ARCHIVE = "review_decision_lock_archive"
|
||||||
|
# #709: post-merge cleanup/audit reconciliation-required durable record.
|
||||||
|
KIND_POST_MERGE_DECISION_RECOVERY = "post_merge_decision_recovery"
|
||||||
|
# #709: truthful record when historical terminal evidence is irrecoverably gone.
|
||||||
|
KIND_IRRECOVERABLE_DECISION_PROVENANCE = "irrecoverable_decision_provenance"
|
||||||
|
# #709 F1: server-side non-forgeable authorization artifact for recovery.
|
||||||
|
KIND_IRRECOVERABLE_PROVENANCE_AUTH = "irrecoverable_provenance_authorization"
|
||||||
|
|
||||||
|
# Kinds that must survive the default session-state TTL (forensic / recovery).
|
||||||
|
RECOVERY_CRITICAL_KINDS = frozenset(
|
||||||
|
{
|
||||||
|
KIND_DECISION_LOCK_ARCHIVE,
|
||||||
|
KIND_POST_MERGE_DECISION_RECOVERY,
|
||||||
|
KIND_IRRECOVERABLE_DECISION_PROVENANCE,
|
||||||
|
KIND_IRRECOVERABLE_PROVENANCE_AUTH,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
||||||
@@ -270,7 +287,11 @@ def identity_match_reasons(
|
|||||||
reasons.append("session state missing recorded_at timestamp (fail closed)")
|
reasons.append("session state missing recorded_at timestamp (fail closed)")
|
||||||
else:
|
else:
|
||||||
age = _now_utc() - recorded_at
|
age = _now_utc() - recorded_at
|
||||||
if age > timedelta(hours=ttl_hours()):
|
kind = (record.get("kind") or "").strip()
|
||||||
|
ttl_exempt = kind in RECOVERY_CRITICAL_KINDS or bool(
|
||||||
|
record.get("recovery_critical")
|
||||||
|
)
|
||||||
|
if age > timedelta(hours=ttl_hours()) and not ttl_exempt:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
f"session state expired after {ttl_hours():g}h (fail closed)"
|
f"session state expired after {ttl_hours():g}h (fail closed)"
|
||||||
)
|
)
|
||||||
@@ -447,3 +468,177 @@ def clear_state(
|
|||||||
profile_identity=profile_identity,
|
profile_identity=profile_identity,
|
||||||
state_dir=state_dir,
|
state_dir=state_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def list_decision_lock_profile_identities(
|
||||||
|
state_dir: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return profile identities that have a durable review_decision_lock file (#709).
|
||||||
|
|
||||||
|
Filename form: ``review_decision_lock-<profile>.json`` (see ``state_key``).
|
||||||
|
Does not validate TTL or identity — callers must load via ``load_state``.
|
||||||
|
"""
|
||||||
|
root = (state_dir or default_state_dir()).strip()
|
||||||
|
if not root or not os.path.isdir(root):
|
||||||
|
return []
|
||||||
|
prefix = f"{_sanitize_segment(KIND_DECISION_LOCK)}-"
|
||||||
|
suffix = ".json"
|
||||||
|
found: list[str] = []
|
||||||
|
try:
|
||||||
|
names = os.listdir(root)
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
for name in names:
|
||||||
|
if not name.startswith(prefix) or not name.endswith(suffix):
|
||||||
|
continue
|
||||||
|
if name.endswith(".lock"):
|
||||||
|
continue
|
||||||
|
mid = name[len(prefix) : -len(suffix)]
|
||||||
|
if mid:
|
||||||
|
found.append(mid)
|
||||||
|
return sorted(set(found))
|
||||||
|
|
||||||
|
|
||||||
|
def load_state_for_profile(
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
profile_identity: str,
|
||||||
|
remote: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
|
state_dir: str | None = None,
|
||||||
|
skip_identity_match: bool = False,
|
||||||
|
enforce_repo_scope: bool = True,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Load durable state for an explicit profile identity (#709 cross-profile).
|
||||||
|
|
||||||
|
When *skip_identity_match* is True, still requires the file's recorded
|
||||||
|
profile_identity to equal the requested profile (anti-stomp), but does not
|
||||||
|
require the *active* session identity to match — needed so a merger can
|
||||||
|
inspect a reviewer lock after merge.
|
||||||
|
|
||||||
|
#709 F3: remote/org/repo filter reasons are **enforced** (not merely
|
||||||
|
computed). When *enforce_repo_scope* is True (default) and the caller
|
||||||
|
supplies remote/org/repo, mismatches fail closed with ``None``.
|
||||||
|
"""
|
||||||
|
# #709 F3: refuse traversal / malformed profile identities.
|
||||||
|
try:
|
||||||
|
from irrecoverable_provenance import assess_profile_path_identity
|
||||||
|
|
||||||
|
path_gate = assess_profile_path_identity(profile_identity)
|
||||||
|
if not path_gate.get("valid"):
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
# Module may be mid-import in edge bootstraps; fall through to
|
||||||
|
# conservative checks below.
|
||||||
|
raw = str(profile_identity or "")
|
||||||
|
if ".." in raw or "/" in raw or "\\" in raw or "\x00" in raw:
|
||||||
|
return None
|
||||||
|
|
||||||
|
profile = current_profile_identity(profile_identity=profile_identity)
|
||||||
|
root = _ensure_state_dir(state_dir)
|
||||||
|
# Refuse symlink escape of the state root (#709 F3).
|
||||||
|
try:
|
||||||
|
real_root = os.path.realpath(root)
|
||||||
|
path = state_file_path(
|
||||||
|
kind=kind,
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
profile_identity=profile,
|
||||||
|
state_dir=root,
|
||||||
|
)
|
||||||
|
real_path = os.path.realpath(path) if os.path.exists(path) else path
|
||||||
|
if os.path.exists(path) and not str(real_path).startswith(
|
||||||
|
str(real_root) + os.sep
|
||||||
|
) and str(real_path) != str(real_root):
|
||||||
|
return None
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
path = state_file_path(
|
||||||
|
kind=kind,
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
profile_identity=profile,
|
||||||
|
state_dir=root,
|
||||||
|
)
|
||||||
|
lock_path = f"{path}.lock"
|
||||||
|
with _exclusive_file_lock(lock_path):
|
||||||
|
envelope = _read_json(path)
|
||||||
|
if not envelope:
|
||||||
|
return None
|
||||||
|
payload = envelope.get("payload")
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
merged = dict(payload)
|
||||||
|
for key in (
|
||||||
|
"kind",
|
||||||
|
"remote",
|
||||||
|
"org",
|
||||||
|
"repo",
|
||||||
|
"profile_identity",
|
||||||
|
"session_profile_lock",
|
||||||
|
"recorded_at",
|
||||||
|
"updated_at",
|
||||||
|
"writer_pid",
|
||||||
|
):
|
||||||
|
if key in envelope and key not in merged:
|
||||||
|
merged[key] = envelope[key]
|
||||||
|
stored = (merged.get("profile_identity") or "").strip()
|
||||||
|
if stored and stored != profile:
|
||||||
|
return None
|
||||||
|
if skip_identity_match:
|
||||||
|
# Enforce remote/org/repo when caller provides them (#709 F3).
|
||||||
|
# Drop only *active session* profile-identity mismatches; keep
|
||||||
|
# expiry, spoof, and repository-scope reasons.
|
||||||
|
scope_remote = remote if enforce_repo_scope else None
|
||||||
|
scope_org = org if enforce_repo_scope else None
|
||||||
|
scope_repo = repo if enforce_repo_scope else None
|
||||||
|
reasons = identity_match_reasons(
|
||||||
|
merged,
|
||||||
|
remote=scope_remote,
|
||||||
|
org=scope_org,
|
||||||
|
repo=scope_repo,
|
||||||
|
profile_identity=stored or profile,
|
||||||
|
)
|
||||||
|
filtered = [
|
||||||
|
r
|
||||||
|
for r in reasons
|
||||||
|
if "profile identity mismatch" not in r
|
||||||
|
or (stored and stored != profile)
|
||||||
|
]
|
||||||
|
# When caller requested a specific remote/org/repo, also fail if the
|
||||||
|
# stored record lacks those identity fields entirely (legacy incomplete).
|
||||||
|
if enforce_repo_scope:
|
||||||
|
for field, want in (
|
||||||
|
("remote", remote),
|
||||||
|
("org", org),
|
||||||
|
("repo", repo),
|
||||||
|
):
|
||||||
|
want_s = (want or "").strip()
|
||||||
|
if not want_s:
|
||||||
|
continue
|
||||||
|
have = (str(merged.get(field) or "")).strip()
|
||||||
|
if not have:
|
||||||
|
filtered.append(
|
||||||
|
f"session state {field} missing on durable record "
|
||||||
|
f"(expected={want_s!r}; fail closed, #709 F3)"
|
||||||
|
)
|
||||||
|
elif have != want_s:
|
||||||
|
# identity_match_reasons already adds mismatch; ensure kept
|
||||||
|
pass
|
||||||
|
if filtered:
|
||||||
|
return None
|
||||||
|
return merged
|
||||||
|
reasons = identity_match_reasons(
|
||||||
|
merged,
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
profile_identity=profile,
|
||||||
|
)
|
||||||
|
if reasons:
|
||||||
|
return None
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|||||||
@@ -1,419 +0,0 @@
|
|||||||
"""Session-immutable MCP mutation context (#714).
|
|
||||||
|
|
||||||
Pins profile, remote, host, repository, identity, and role for the life of an
|
|
||||||
MCP process (or until an explicit ``gitea_activate_profile`` re-bind).
|
|
||||||
|
|
||||||
Capability resolution and mutation gates must evaluate only the active
|
|
||||||
profile for the requested remote. Silent cross-host / cross-profile
|
|
||||||
substitution is forbidden.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import threading
|
|
||||||
import urllib.parse
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class _SessionContext:
|
|
||||||
"""One atomic, immutable process-session binding."""
|
|
||||||
|
|
||||||
profile_name: str | None
|
|
||||||
remote: str | None
|
|
||||||
host: str | None
|
|
||||||
identity: str | None
|
|
||||||
repository: str | None
|
|
||||||
org: str | None
|
|
||||||
role_kind: str | None
|
|
||||||
expected_username: str | None
|
|
||||||
source: str
|
|
||||||
pid: int
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"profile_name": self.profile_name,
|
|
||||||
"remote": self.remote,
|
|
||||||
"host": self.host,
|
|
||||||
"identity": self.identity,
|
|
||||||
"repository": self.repository,
|
|
||||||
"org": self.org,
|
|
||||||
"role_kind": self.role_kind,
|
|
||||||
"expected_username": self.expected_username,
|
|
||||||
"source": self.source,
|
|
||||||
"pid": self.pid,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Process-local only — never a shared file (same rationale as mutation authority).
|
|
||||||
# The frozen value prevents partial mutation, while the lock makes first-bind and
|
|
||||||
# sanctioned rebind atomic across concurrent MCP calls.
|
|
||||||
_SESSION_CONTEXT: _SessionContext | None = None
|
|
||||||
_SESSION_CONTEXT_LOCK = threading.RLock()
|
|
||||||
|
|
||||||
|
|
||||||
def _reset_session_context_for_testing() -> None:
|
|
||||||
"""Reset at a pytest test boundary; unavailable to production callers.
|
|
||||||
|
|
||||||
Production sessions transition only through process startup/PID change or
|
|
||||||
the explicit profile-activation rebind. Keeping this helper private and
|
|
||||||
requiring pytest's per-test marker prevents it from becoming an MCP/runtime
|
|
||||||
bypass.
|
|
||||||
"""
|
|
||||||
if "PYTEST_CURRENT_TEST" not in os.environ:
|
|
||||||
raise RuntimeError("session context reset is restricted to pytest boundaries")
|
|
||||||
global _SESSION_CONTEXT
|
|
||||||
with _SESSION_CONTEXT_LOCK:
|
|
||||||
_SESSION_CONTEXT = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_session_context() -> dict[str, Any] | None:
|
|
||||||
"""Return a detached snapshot of the bound context, or None if unbound."""
|
|
||||||
with _SESSION_CONTEXT_LOCK:
|
|
||||||
if _SESSION_CONTEXT is None:
|
|
||||||
return None
|
|
||||||
return _SESSION_CONTEXT.as_dict()
|
|
||||||
|
|
||||||
|
|
||||||
def profile_host(profile: dict | None) -> str | None:
|
|
||||||
"""Hostname from profile base_url, lowercased, or None."""
|
|
||||||
if not profile:
|
|
||||||
return None
|
|
||||||
base = (profile.get("base_url") or "").strip()
|
|
||||||
if not base:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = urllib.parse.urlparse(base)
|
|
||||||
host = (parsed.netloc or parsed.path or "").strip().lower()
|
|
||||||
return host or None
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def remote_host(remote: str | None, remotes: dict | None) -> str | None:
|
|
||||||
"""Hostname for a known remote key."""
|
|
||||||
if not remote or not remotes:
|
|
||||||
return None
|
|
||||||
entry = remotes.get(remote) or {}
|
|
||||||
return (entry.get("host") or "").strip().lower() or None
|
|
||||||
|
|
||||||
|
|
||||||
def profile_matches_remote(
|
|
||||||
profile: dict | None,
|
|
||||||
remote: str | None,
|
|
||||||
remotes: dict | None,
|
|
||||||
*,
|
|
||||||
contexts: dict | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""True when *profile* is bound to the same host/context as *remote*."""
|
|
||||||
if not profile or not remote:
|
|
||||||
return False
|
|
||||||
r_host = remote_host(remote, remotes)
|
|
||||||
p_host = profile_host(profile)
|
|
||||||
if r_host and p_host:
|
|
||||||
return r_host == p_host
|
|
||||||
# Fall back to context name heuristics when base_url missing.
|
|
||||||
ctx = (profile.get("context") or "").strip().lower()
|
|
||||||
if not ctx or not contexts:
|
|
||||||
return False
|
|
||||||
ctx_data = contexts.get(ctx) or {}
|
|
||||||
gitea = ctx_data.get("gitea") or {}
|
|
||||||
base = (gitea.get("base_url") or "").strip()
|
|
||||||
if not base or not r_host:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
parsed = urllib.parse.urlparse(base)
|
|
||||||
c_host = (parsed.netloc or parsed.path or "").strip().lower()
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
return bool(c_host) and c_host == r_host
|
|
||||||
|
|
||||||
|
|
||||||
def bind_session_context(
|
|
||||||
*,
|
|
||||||
profile_name: str,
|
|
||||||
remote: str | None,
|
|
||||||
host: str | None,
|
|
||||||
identity: str | None,
|
|
||||||
repository: str | None = None,
|
|
||||||
org: str | None = None,
|
|
||||||
role_kind: str | None = None,
|
|
||||||
expected_username: str | None = None,
|
|
||||||
source: str = "bind",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Atomically bind/re-bind context (the explicit activation path)."""
|
|
||||||
with _SESSION_CONTEXT_LOCK:
|
|
||||||
return _bind_session_context_unlocked(
|
|
||||||
profile_name=profile_name,
|
|
||||||
remote=remote,
|
|
||||||
host=host,
|
|
||||||
identity=identity,
|
|
||||||
repository=repository,
|
|
||||||
org=org,
|
|
||||||
role_kind=role_kind,
|
|
||||||
expected_username=expected_username,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _bind_session_context_unlocked(
|
|
||||||
*,
|
|
||||||
profile_name: str,
|
|
||||||
remote: str | None,
|
|
||||||
host: str | None,
|
|
||||||
identity: str | None,
|
|
||||||
repository: str | None,
|
|
||||||
org: str | None,
|
|
||||||
role_kind: str | None,
|
|
||||||
expected_username: str | None,
|
|
||||||
source: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Store a complete immutable context while the caller holds the lock."""
|
|
||||||
global _SESSION_CONTEXT
|
|
||||||
_SESSION_CONTEXT = _SessionContext(
|
|
||||||
profile_name=(profile_name or "").strip() or None,
|
|
||||||
remote=(remote or "").strip() or None,
|
|
||||||
host=(host or "").strip().lower() or None,
|
|
||||||
identity=(identity or "").strip() or None,
|
|
||||||
repository=(repository or "").strip() or None,
|
|
||||||
org=(org or "").strip() or None,
|
|
||||||
role_kind=(role_kind or "").strip() or None,
|
|
||||||
expected_username=(expected_username or "").strip() or None,
|
|
||||||
source=source,
|
|
||||||
pid=os.getpid(),
|
|
||||||
)
|
|
||||||
return _SESSION_CONTEXT.as_dict()
|
|
||||||
|
|
||||||
|
|
||||||
def seed_session_context_if_unbound(
|
|
||||||
*,
|
|
||||||
profile_name: str,
|
|
||||||
remote: str | None,
|
|
||||||
host: str | None,
|
|
||||||
identity: str | None,
|
|
||||||
repository: str | None = None,
|
|
||||||
org: str | None = None,
|
|
||||||
role_kind: str | None = None,
|
|
||||||
expected_username: str | None = None,
|
|
||||||
source: str = "seed",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Atomically bind only when this process has no current context.
|
|
||||||
|
|
||||||
A changed environment or an interleaved call is not a session boundary and
|
|
||||||
therefore cannot replace an established binding. A newly started/forked
|
|
||||||
process is recognized by PID; explicit ``gitea_activate_profile`` uses
|
|
||||||
:func:`bind_session_context` as its sanctioned logical-session transition.
|
|
||||||
"""
|
|
||||||
with _SESSION_CONTEXT_LOCK:
|
|
||||||
if _SESSION_CONTEXT is None or _SESSION_CONTEXT.pid != os.getpid():
|
|
||||||
return _bind_session_context_unlocked(
|
|
||||||
profile_name=profile_name,
|
|
||||||
remote=remote,
|
|
||||||
host=host,
|
|
||||||
identity=identity,
|
|
||||||
repository=repository,
|
|
||||||
org=org,
|
|
||||||
role_kind=role_kind,
|
|
||||||
expected_username=expected_username,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
return _SESSION_CONTEXT.as_dict()
|
|
||||||
|
|
||||||
|
|
||||||
def assess_session_context(
|
|
||||||
*,
|
|
||||||
profile_name: str | None,
|
|
||||||
remote: str | None,
|
|
||||||
host: str | None = None,
|
|
||||||
identity: str | None = None,
|
|
||||||
repository: str | None = None,
|
|
||||||
org: str | None = None,
|
|
||||||
expected_username: str | None = None,
|
|
||||||
require_bound: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Compare live values against the bound session context.
|
|
||||||
|
|
||||||
Returns ``proven`` / ``block`` / ``reasons``. When unbound and
|
|
||||||
``require_bound`` is false, does not block (caller may seed). When
|
|
||||||
unbound and ``require_bound`` is true, fails closed.
|
|
||||||
"""
|
|
||||||
reasons: list[str] = []
|
|
||||||
with _SESSION_CONTEXT_LOCK:
|
|
||||||
bound = _SESSION_CONTEXT
|
|
||||||
ctx = bound.as_dict() if bound is not None else None
|
|
||||||
if ctx is None or ctx.get("pid") != os.getpid():
|
|
||||||
if require_bound:
|
|
||||||
reasons.append(
|
|
||||||
"session mutation context is unbound; call gitea_whoami or "
|
|
||||||
"gitea_activate_profile before mutating (fail closed)"
|
|
||||||
)
|
|
||||||
return _assessment(False, reasons, ctx)
|
|
||||||
return _assessment(True, reasons, ctx)
|
|
||||||
|
|
||||||
live_profile = (profile_name or "").strip() or None
|
|
||||||
live_remote = (remote or "").strip() or None
|
|
||||||
live_host = (host or "").strip().lower() or None
|
|
||||||
live_identity = (identity or "").strip() or None
|
|
||||||
live_repo = (repository or "").strip() or None
|
|
||||||
live_org = (org or "").strip() or None
|
|
||||||
|
|
||||||
if ctx.get("profile_name") and live_profile and live_profile != ctx.get("profile_name"):
|
|
||||||
reasons.append(
|
|
||||||
f"profile drift: live '{live_profile}' != bound "
|
|
||||||
f"'{ctx.get('profile_name')}' (fail closed)"
|
|
||||||
)
|
|
||||||
if ctx.get("remote") and live_remote and live_remote != ctx.get("remote"):
|
|
||||||
reasons.append(
|
|
||||||
f"remote drift: live '{live_remote}' != bound "
|
|
||||||
f"'{ctx.get('remote')}' (fail closed)"
|
|
||||||
)
|
|
||||||
if ctx.get("host") and live_host and live_host != ctx.get("host"):
|
|
||||||
reasons.append(
|
|
||||||
f"host drift: live '{live_host}' != bound "
|
|
||||||
f"'{ctx.get('host')}' (fail closed)"
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
ctx.get("identity")
|
|
||||||
and live_identity
|
|
||||||
and live_identity != ctx.get("identity")
|
|
||||||
):
|
|
||||||
reasons.append(
|
|
||||||
f"identity drift: live '{live_identity}' != bound "
|
|
||||||
f"'{ctx.get('identity')}' (fail closed)"
|
|
||||||
)
|
|
||||||
if ctx.get("repository") and live_repo and live_repo != ctx.get("repository"):
|
|
||||||
reasons.append(
|
|
||||||
f"repository drift: live '{live_repo}' != bound "
|
|
||||||
f"'{ctx.get('repository')}' (fail closed)"
|
|
||||||
)
|
|
||||||
if ctx.get("org") and live_org and live_org != ctx.get("org"):
|
|
||||||
reasons.append(
|
|
||||||
f"org drift: live '{live_org}' != bound "
|
|
||||||
f"'{ctx.get('org')}' (fail closed)"
|
|
||||||
)
|
|
||||||
|
|
||||||
expected = expected_username or ctx.get("expected_username")
|
|
||||||
if expected and live_identity and live_identity != expected:
|
|
||||||
reasons.append(
|
|
||||||
f"identity mismatch: authenticated '{live_identity}' != "
|
|
||||||
f"profile expected '{expected}' (fail closed)"
|
|
||||||
)
|
|
||||||
|
|
||||||
return _assessment(not reasons, reasons, ctx)
|
|
||||||
|
|
||||||
|
|
||||||
def assess_identity_match(
|
|
||||||
*,
|
|
||||||
authenticated: str | None,
|
|
||||||
expected_username: str | None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Fail closed when profile declares a username that does not match live auth."""
|
|
||||||
reasons: list[str] = []
|
|
||||||
auth = (authenticated or "").strip() or None
|
|
||||||
expected = (expected_username or "").strip() or None
|
|
||||||
if expected and auth and auth != expected:
|
|
||||||
reasons.append(
|
|
||||||
f"identity mismatch: authenticated '{auth}' != "
|
|
||||||
f"profile expected '{expected}' (fail closed)"
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"proven": not reasons,
|
|
||||||
"block": bool(reasons),
|
|
||||||
"reasons": reasons,
|
|
||||||
"authenticated": auth,
|
|
||||||
"expected_username": expected,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def filter_profiles_for_remote(
|
|
||||||
config: dict | None,
|
|
||||||
remote: str | None,
|
|
||||||
remotes: dict | None,
|
|
||||||
) -> list[str]:
|
|
||||||
"""Profile names whose host/context matches *remote* (enabled only)."""
|
|
||||||
if not config or not remote:
|
|
||||||
return []
|
|
||||||
profiles = config.get("profiles") or {}
|
|
||||||
contexts = config.get("contexts") or {}
|
|
||||||
names: list[str] = []
|
|
||||||
for name, data in profiles.items():
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
continue
|
|
||||||
if not data.get("enabled", True):
|
|
||||||
continue
|
|
||||||
if profile_matches_remote(data, remote, remotes, contexts=contexts):
|
|
||||||
names.append(name)
|
|
||||||
return sorted(names)
|
|
||||||
|
|
||||||
|
|
||||||
def profile_allowed_for_remote(
|
|
||||||
profile: dict | None,
|
|
||||||
remote: str | None,
|
|
||||||
remotes: dict | None,
|
|
||||||
*,
|
|
||||||
contexts: dict | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Assess whether the active profile may serve *remote*."""
|
|
||||||
reasons: list[str] = []
|
|
||||||
if not profile:
|
|
||||||
reasons.append("active profile unresolved (fail closed)")
|
|
||||||
return {"proven": False, "block": True, "reasons": reasons}
|
|
||||||
if not remote:
|
|
||||||
return {"proven": True, "block": False, "reasons": reasons}
|
|
||||||
# Legacy env-only profiles have no configured base URL or v2 context. Their
|
|
||||||
# first call may establish the process remote/host pin; after that,
|
|
||||||
# assess_session_context rejects any drift. Configured v2 profiles still
|
|
||||||
# require positive host/context alignment here.
|
|
||||||
if not profile_host(profile) and not (profile.get("context") or "").strip():
|
|
||||||
return {"proven": True, "block": False, "reasons": reasons}
|
|
||||||
if not profile_matches_remote(profile, remote, remotes, contexts=contexts):
|
|
||||||
p_name = profile.get("profile_name") or profile.get("name") or "(unknown)"
|
|
||||||
p_host = profile_host(profile) or "(none)"
|
|
||||||
r_host = remote_host(remote, remotes) or "(none)"
|
|
||||||
reasons.append(
|
|
||||||
f"cross-host profile denial: profile '{p_name}' (host '{p_host}') "
|
|
||||||
f"cannot serve remote '{remote}' (host '{r_host}') (fail closed)"
|
|
||||||
)
|
|
||||||
return {"proven": not reasons, "block": bool(reasons), "reasons": reasons}
|
|
||||||
|
|
||||||
|
|
||||||
def mutation_context_audit_fields(
|
|
||||||
ctx: Mapping[str, Any] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Fields to include in pre-mutation audit records."""
|
|
||||||
data = ctx if ctx is not None else get_session_context()
|
|
||||||
if not data:
|
|
||||||
return {
|
|
||||||
"session_context_bound": False,
|
|
||||||
"session_profile": None,
|
|
||||||
"session_remote": None,
|
|
||||||
"session_host": None,
|
|
||||||
"session_identity": None,
|
|
||||||
"session_repository": None,
|
|
||||||
"session_org": None,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"session_context_bound": True,
|
|
||||||
"session_profile": data.get("profile_name"),
|
|
||||||
"session_remote": data.get("remote"),
|
|
||||||
"session_host": data.get("host"),
|
|
||||||
"session_identity": data.get("identity"),
|
|
||||||
"session_repository": data.get("repository"),
|
|
||||||
"session_org": data.get("org"),
|
|
||||||
"session_role_kind": data.get("role_kind"),
|
|
||||||
"session_context_source": data.get("source"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _assessment(
|
|
||||||
proven: bool, reasons: list[str], ctx: Mapping[str, Any] | None
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"proven": proven,
|
|
||||||
"block": not proven,
|
|
||||||
"reasons": list(reasons),
|
|
||||||
"bound_context": dict(ctx) if ctx else None,
|
|
||||||
"audit": mutation_context_audit_fields(ctx),
|
|
||||||
}
|
|
||||||
@@ -438,3 +438,252 @@ def format_cleanup_audit_comment(audit: dict[str, Any]) -> str:
|
|||||||
"This path only clears a lock when the referenced PR is merged/closed.",
|
"This path only clears a lock when the referenced PR is merged/closed.",
|
||||||
]
|
]
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# #709 cross-profile cleanup, overwrite protection, recovery provenance
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def has_unresolved_terminal_evidence(lock: dict | None) -> bool:
|
||||||
|
"""True when *lock* still carries a terminal live mutation ledger."""
|
||||||
|
return last_terminal_mutation(lock) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def assess_init_overwrite(
|
||||||
|
existing_lock: dict | None,
|
||||||
|
*,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Whether init_review_decision_lock may replace an existing durable lock (#709 AC2).
|
||||||
|
|
||||||
|
Unresolved terminal evidence must never be silently replaced by an empty
|
||||||
|
initialized lock. *force* does not authorize destruction of terminal
|
||||||
|
ledgers — only explicit cleanup / archive transitions may remove them.
|
||||||
|
"""
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"overwrite_allowed": True,
|
||||||
|
"has_existing": existing_lock is not None,
|
||||||
|
"has_unresolved_terminal": False,
|
||||||
|
"reasons": [],
|
||||||
|
"last_terminal_pr": None,
|
||||||
|
"last_terminal_action": None,
|
||||||
|
"existing_profile_identity": None,
|
||||||
|
}
|
||||||
|
if existing_lock is None:
|
||||||
|
result["reasons"].append("no existing lock; empty init allowed")
|
||||||
|
return result
|
||||||
|
result["existing_profile_identity"] = (
|
||||||
|
existing_lock.get("profile_identity")
|
||||||
|
or existing_lock.get("session_profile_lock")
|
||||||
|
or existing_lock.get("session_profile")
|
||||||
|
)
|
||||||
|
last = last_terminal_mutation(existing_lock)
|
||||||
|
if last is None:
|
||||||
|
result["reasons"].append(
|
||||||
|
"existing lock has no terminal mutations; re-init allowed"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
result["has_unresolved_terminal"] = True
|
||||||
|
result["overwrite_allowed"] = False
|
||||||
|
result["last_terminal_pr"] = last.get("pr_number")
|
||||||
|
result["last_terminal_action"] = last.get("action")
|
||||||
|
result["reasons"].append(
|
||||||
|
"refuse empty re-init: unresolved terminal decision-lock evidence "
|
||||||
|
f"present ({last.get('action')} on PR #{last.get('pr_number')}); "
|
||||||
|
"use gitea_cleanup_stale_review_decision_lock when moot, or archive "
|
||||||
|
f"via sanctioned recovery — force={force!r} does not authorize overwrite "
|
||||||
|
"(#709 AC2)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def lock_targets_merged_pr_approval(
|
||||||
|
lock: dict | None,
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
expected_head_sha: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""True when *lock*'s last terminal mutation is approve of *pr_number*.
|
||||||
|
|
||||||
|
When *expected_head_sha* is provided, a **recorded** terminal head must
|
||||||
|
match. Legacy same-repo approve locks with no recorded head do **not**
|
||||||
|
match (fail closed, #709 F3 residual / review 435) — callers must use the
|
||||||
|
strict secondary path that refuses no-head clears.
|
||||||
|
"""
|
||||||
|
last = last_terminal_mutation(lock)
|
||||||
|
if last is None:
|
||||||
|
return False
|
||||||
|
if last.get("action") != "approve":
|
||||||
|
return False
|
||||||
|
if last.get("pr_number") != pr_number:
|
||||||
|
return False
|
||||||
|
if expected_head_sha:
|
||||||
|
want = normalize_head_sha(expected_head_sha)
|
||||||
|
if not want:
|
||||||
|
return False
|
||||||
|
locked = mutation_head_sha(last, lock)
|
||||||
|
# Require recorded-head match; unrecorded head is not a match (#709 F3).
|
||||||
|
if not locked or not heads_equal(locked, want):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def build_post_merge_recovery_record(
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
head_sha: str | None,
|
||||||
|
merge_commit_sha: str | None,
|
||||||
|
target_profile_identity: str | None,
|
||||||
|
failed_step: str,
|
||||||
|
error: str | None,
|
||||||
|
remote: str | None,
|
||||||
|
org: str | None,
|
||||||
|
repo: str | None,
|
||||||
|
actor_username: str | None,
|
||||||
|
profile_name: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Durable recovery-required payload after irreversible merge (#709 AC3)."""
|
||||||
|
return {
|
||||||
|
"event": "post_merge_decision_lock_recovery_required",
|
||||||
|
"status": "recovery_required",
|
||||||
|
"issue_ref": "#709",
|
||||||
|
"recovery_critical": True,
|
||||||
|
"applied": False, # never claim historical cleanup succeeded
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"head_sha": normalize_head_sha(head_sha),
|
||||||
|
"merge_commit_sha": merge_commit_sha,
|
||||||
|
"target_profile_identity": target_profile_identity,
|
||||||
|
"failed_step": failed_step,
|
||||||
|
"error": error,
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"actor_username": actor_username,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"required_recovery_action": (
|
||||||
|
"retry cross-profile decision-lock cleanup and audit publication "
|
||||||
|
"for the merged PR; if terminal evidence is gone, use "
|
||||||
|
"gitea_record_irrecoverable_decision_lock_provenance"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_irrecoverable_provenance_record(
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
head_sha: str | None,
|
||||||
|
remote: str | None,
|
||||||
|
org: str | None,
|
||||||
|
repo: str | None,
|
||||||
|
actor_username: str | None,
|
||||||
|
profile_name: str | None,
|
||||||
|
reason: str,
|
||||||
|
incident_ref: str | None = None,
|
||||||
|
# Deprecated kwargs retained only so stale call sites fail closed:
|
||||||
|
operator_authorized: bool | None = None,
|
||||||
|
# Required for merger-acceptable records (#709 F1):
|
||||||
|
authorization: dict[str, Any] | None = None,
|
||||||
|
incident_issue: int | None = None,
|
||||||
|
incident_comment_id: int | None = None,
|
||||||
|
destroyed_subject: str | None = None,
|
||||||
|
historical_provenance_subject: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Truthful absence-of-proof record (#709 AC5). Never sets applied=True.
|
||||||
|
|
||||||
|
Caller-supplied ``operator_authorized`` is **ignored** as authorization
|
||||||
|
evidence (review 434 F1). Prefer
|
||||||
|
:func:`irrecoverable_provenance.build_irrecoverable_provenance_record`
|
||||||
|
with a verified server-side authorization artifact.
|
||||||
|
"""
|
||||||
|
# Explicitly ignore deprecated self-assertable Boolean.
|
||||||
|
_ = operator_authorized
|
||||||
|
if authorization is not None and incident_issue is not None and incident_comment_id is not None:
|
||||||
|
from irrecoverable_provenance import (
|
||||||
|
build_irrecoverable_provenance_record as _build,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _build(
|
||||||
|
pr_number=int(pr_number),
|
||||||
|
head_sha=str(head_sha or ""),
|
||||||
|
remote=str(remote or ""),
|
||||||
|
org=str(org or ""),
|
||||||
|
repo=str(repo or ""),
|
||||||
|
actor_username=actor_username,
|
||||||
|
profile_name=profile_name,
|
||||||
|
reason=reason,
|
||||||
|
incident_issue=int(incident_issue),
|
||||||
|
incident_comment_id=int(incident_comment_id),
|
||||||
|
authorization=authorization,
|
||||||
|
destroyed_subject=destroyed_subject,
|
||||||
|
historical_provenance_subject=historical_provenance_subject
|
||||||
|
or destroyed_subject,
|
||||||
|
)
|
||||||
|
# Fail-closed skeleton when no server authorization is supplied: never
|
||||||
|
# sets merger_may_accept True (even if operator_authorized was True).
|
||||||
|
return {
|
||||||
|
"event": "irrecoverable_decision_lock_provenance",
|
||||||
|
"status": "provenance_irrecoverable",
|
||||||
|
"record_type": "irrecoverable_decision_provenance",
|
||||||
|
"operator_recovery_required": True,
|
||||||
|
"issue_ref": "#709",
|
||||||
|
"recovery_critical": True,
|
||||||
|
"applied": False,
|
||||||
|
"historical_cleanup_proven": False,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"head_sha": normalize_head_sha(head_sha),
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"actor_username": actor_username,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"reason": reason,
|
||||||
|
"incident_ref": incident_ref,
|
||||||
|
"incident_issue": incident_issue,
|
||||||
|
"incident_comment_id": incident_comment_id,
|
||||||
|
"authorization_verified": False,
|
||||||
|
"merger_may_accept": False,
|
||||||
|
"acceptance_rule": (
|
||||||
|
"Merger may accept this record only when a server-side "
|
||||||
|
"authorization artifact verifies for remote/org/repo/PR/exact "
|
||||||
|
"head/incident, the record is durable and read back, and normal "
|
||||||
|
"merge gates still pass. Caller Booleans never authorize. This "
|
||||||
|
"does not prove historical cleanup."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_irrecoverable_audit_comment(record: dict[str, Any]) -> str:
|
||||||
|
"""Markdown body for irrecoverable provenance audit (no applied=true claim)."""
|
||||||
|
from irrecoverable_provenance import (
|
||||||
|
format_irrecoverable_audit_comment as _fmt,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _fmt(record)
|
||||||
|
|
||||||
|
|
||||||
|
def format_post_merge_recovery_comment(record: dict[str, Any]) -> str:
|
||||||
|
"""Markdown body for post-merge recovery-required audit."""
|
||||||
|
lines = [
|
||||||
|
"## Post-merge decision-lock recovery required (#709)",
|
||||||
|
"",
|
||||||
|
"Status: **RECOVERY_REQUIRED** (merge is irreversible; cleanup/audit incomplete)",
|
||||||
|
"",
|
||||||
|
f"- actor: `{record.get('actor_username')}`",
|
||||||
|
f"- profile: `{record.get('profile_name')}`",
|
||||||
|
f"- timestamp: `{record.get('timestamp')}`",
|
||||||
|
f"- PR: `#{record.get('pr_number')}`",
|
||||||
|
f"- head_sha: `{record.get('head_sha')}`",
|
||||||
|
f"- merge_commit_sha: `{record.get('merge_commit_sha')}`",
|
||||||
|
f"- target_profile_identity: `{record.get('target_profile_identity')}`",
|
||||||
|
f"- failed_step: `{record.get('failed_step')}`",
|
||||||
|
f"- error: `{record.get('error')}`",
|
||||||
|
"",
|
||||||
|
f"Required action: {record.get('required_recovery_action')}",
|
||||||
|
"",
|
||||||
|
"applied=false — do not treat this as successful cleanup evidence.",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,32 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.review",
|
"permission": "gitea.pr.review",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
},
|
},
|
||||||
|
# #709: truthful absence-of-proof recovery (server-side auth + record + consume).
|
||||||
|
# Dedicated mutation capability — gitea.read is insufficient (review 434 F1).
|
||||||
|
"issue_irrecoverable_provenance_authorization": {
|
||||||
|
"permission": "gitea.decision_lock.irrecoverable_recovery",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"gitea_issue_irrecoverable_provenance_authorization": {
|
||||||
|
"permission": "gitea.decision_lock.irrecoverable_recovery",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"record_irrecoverable_decision_lock_provenance": {
|
||||||
|
"permission": "gitea.decision_lock.irrecoverable_recovery",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"gitea_record_irrecoverable_decision_lock_provenance": {
|
||||||
|
"permission": "gitea.decision_lock.irrecoverable_recovery",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"consume_irrecoverable_decision_lock_provenance": {
|
||||||
|
"permission": "gitea.pr.merge",
|
||||||
|
"role": "merger",
|
||||||
|
},
|
||||||
|
"gitea_consume_irrecoverable_decision_lock_provenance": {
|
||||||
|
"permission": "gitea.pr.merge",
|
||||||
|
"role": "merger",
|
||||||
|
},
|
||||||
"delete_branch": {
|
"delete_branch": {
|
||||||
"permission": "gitea.branch.delete",
|
"permission": "gitea.branch.delete",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
|
|||||||
+17
-35
@@ -26,14 +26,6 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
Pin ``default_state_dir`` / ``DEFAULT_STATE_DIR`` to a per-test temp dir
|
Pin ``default_state_dir`` / ``DEFAULT_STATE_DIR`` to a per-test temp dir
|
||||||
so durable load/save never touches host state even after env clears.
|
so durable load/save never touches host state even after env clears.
|
||||||
"""
|
"""
|
||||||
import session_context_binding as session_ctx
|
|
||||||
|
|
||||||
# Each pytest item is an independent logical MCP session. Reset both before
|
|
||||||
# and after the item; the post-yield reset is in a finally block so an
|
|
||||||
# assertion, exception, or unittest teardown failure cannot pollute the
|
|
||||||
# next item. Production code has no automatic per-call reset path.
|
|
||||||
session_ctx._reset_session_context_for_testing()
|
|
||||||
|
|
||||||
for env_key in [
|
for env_key in [
|
||||||
"GITEA_SESSION_PROFILE_LOCK",
|
"GITEA_SESSION_PROFILE_LOCK",
|
||||||
"GITEA_ACTIVE_WORKTREE",
|
"GITEA_ACTIVE_WORKTREE",
|
||||||
@@ -88,15 +80,9 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
try:
|
try:
|
||||||
import mcp_server
|
import mcp_server
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
_state_tmp.cleanup()
|
||||||
yield
|
yield
|
||||||
finally:
|
|
||||||
session_ctx._reset_session_context_for_testing()
|
|
||||||
_state_tmp.cleanup()
|
|
||||||
return
|
return
|
||||||
import gitea_config
|
|
||||||
|
|
||||||
monkeypatch.setattr(gitea_config, "_active_profile_override", None)
|
|
||||||
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
|
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
|
||||||
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
|
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
|
||||||
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
|
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
|
||||||
@@ -127,23 +113,19 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
capability_stop_terminal.clear()
|
capability_stop_terminal.clear()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
yield
|
||||||
try:
|
try:
|
||||||
yield
|
import capability_stop_terminal
|
||||||
finally:
|
capability_stop_terminal.clear()
|
||||||
try:
|
except Exception:
|
||||||
import capability_stop_terminal
|
pass
|
||||||
capability_stop_terminal.clear()
|
try:
|
||||||
except Exception:
|
import review_workflow_load
|
||||||
pass
|
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
||||||
try:
|
except Exception:
|
||||||
import review_workflow_load
|
pass
|
||||||
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
try:
|
||||||
except Exception:
|
mcp_server._REVIEW_DECISION_LOCK = None
|
||||||
pass
|
except Exception:
|
||||||
try:
|
pass
|
||||||
mcp_server._REVIEW_DECISION_LOCK = None
|
_state_tmp.cleanup()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
gitea_config._active_profile_override = None
|
|
||||||
session_ctx._reset_session_context_for_testing()
|
|
||||||
_state_tmp.cleanup()
|
|
||||||
|
|||||||
@@ -227,7 +227,6 @@ class TestCommitPayloads(unittest.TestCase):
|
|||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
def test_commit_files_traversal_blocked(self, _auth, mock_api):
|
def test_commit_files_traversal_blocked(self, _auth, mock_api):
|
||||||
mock_api.return_value = {"login": "author-user"}
|
|
||||||
# Remove active lock file to ensure it fails on traversal/invalid locks
|
# Remove active lock file to ensure it fails on traversal/invalid locks
|
||||||
os.remove(self.lock_file_path)
|
os.remove(self.lock_file_path)
|
||||||
|
|
||||||
@@ -249,7 +248,6 @@ class TestCommitPayloads(unittest.TestCase):
|
|||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
def test_commit_files_outside_scope_blocked(self, _auth, mock_api):
|
def test_commit_files_outside_scope_blocked(self, _auth, mock_api):
|
||||||
mock_api.return_value = {"login": "author-user"}
|
|
||||||
with patch.dict(os.environ, self._env("full-author"), clear=True):
|
with patch.dict(os.environ, self._env("full-author"), clear=True):
|
||||||
with self.assertRaises(ValueError) as ctx:
|
with self.assertRaises(ValueError) as ctx:
|
||||||
mcp_server.gitea_commit_files(
|
mcp_server.gitea_commit_files(
|
||||||
@@ -268,7 +266,6 @@ class TestCommitPayloads(unittest.TestCase):
|
|||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
def test_commit_files_multiple_sources_blocked(self, _auth, mock_api):
|
def test_commit_files_multiple_sources_blocked(self, _auth, mock_api):
|
||||||
mock_api.return_value = {"login": "author-user"}
|
|
||||||
with patch.dict(os.environ, self._env("full-author"), clear=True):
|
with patch.dict(os.environ, self._env("full-author"), clear=True):
|
||||||
with self.assertRaises(ValueError) as ctx:
|
with self.assertRaises(ValueError) as ctx:
|
||||||
mcp_server.gitea_commit_files(
|
mcp_server.gitea_commit_files(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,490 +0,0 @@
|
|||||||
"""#714: fail closed on cross-host MCP profile drift and capability substitution."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import threading
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
import gitea_config # noqa: E402
|
|
||||||
import mcp_server # noqa: E402
|
|
||||||
import session_context_binding as session_ctx # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
CONFIG_714 = {
|
|
||||||
"version": 2,
|
|
||||||
"allow_runtime_switching": True,
|
|
||||||
"contexts": {
|
|
||||||
"prgs": {
|
|
||||||
"enabled": True,
|
|
||||||
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
|
||||||
},
|
|
||||||
"mdcps": {
|
|
||||||
"enabled": True,
|
|
||||||
"gitea": {"enabled": True, "base_url": "https://gitea.dadeschools.net"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"profiles": {
|
|
||||||
"prgs-author": {
|
|
||||||
"enabled": True,
|
|
||||||
"context": "prgs",
|
|
||||||
"role": "author",
|
|
||||||
"username": "jcwalker3",
|
|
||||||
"base_url": "https://gitea.prgs.cc",
|
|
||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"},
|
|
||||||
"allowed_operations": [
|
|
||||||
"gitea.read",
|
|
||||||
"gitea.issue.create",
|
|
||||||
"gitea.issue.comment",
|
|
||||||
"gitea.issue.close",
|
|
||||||
"gitea.pr.create",
|
|
||||||
"gitea.pr.comment",
|
|
||||||
"gitea.branch.create",
|
|
||||||
"gitea.branch.push",
|
|
||||||
"gitea.repo.commit",
|
|
||||||
],
|
|
||||||
"forbidden_operations": [
|
|
||||||
"gitea.pr.approve",
|
|
||||||
"gitea.pr.merge",
|
|
||||||
"gitea.pr.request_changes",
|
|
||||||
],
|
|
||||||
"execution_profile": "prgs-author",
|
|
||||||
},
|
|
||||||
"mdcps-reviewer": {
|
|
||||||
"enabled": True,
|
|
||||||
"context": "mdcps",
|
|
||||||
"role": "reviewer",
|
|
||||||
"username": "913443",
|
|
||||||
"base_url": "https://gitea.dadeschools.net",
|
|
||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_MDCPS_REVIEWER"},
|
|
||||||
"allowed_operations": [
|
|
||||||
"gitea.read",
|
|
||||||
"gitea.pr.review",
|
|
||||||
"gitea.pr.comment",
|
|
||||||
"gitea.pr.approve",
|
|
||||||
"gitea.pr.request_changes",
|
|
||||||
],
|
|
||||||
"forbidden_operations": [
|
|
||||||
"gitea.branch.create",
|
|
||||||
"gitea.branch.push",
|
|
||||||
"gitea.pr.create",
|
|
||||||
"gitea.pr.merge",
|
|
||||||
"gitea.repo.commit",
|
|
||||||
],
|
|
||||||
"execution_profile": "mdcps-reviewer",
|
|
||||||
},
|
|
||||||
"mdcps-author": {
|
|
||||||
"enabled": True,
|
|
||||||
"context": "mdcps",
|
|
||||||
"role": "author",
|
|
||||||
"username": "913443",
|
|
||||||
"base_url": "https://gitea.dadeschools.net",
|
|
||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_MDCPS_AUTHOR"},
|
|
||||||
"allowed_operations": [
|
|
||||||
"gitea.read",
|
|
||||||
"gitea.issue.create",
|
|
||||||
"gitea.issue.comment",
|
|
||||||
"gitea.pr.create",
|
|
||||||
"gitea.pr.comment",
|
|
||||||
"gitea.branch.create",
|
|
||||||
"gitea.branch.push",
|
|
||||||
"gitea.repo.commit",
|
|
||||||
],
|
|
||||||
"forbidden_operations": [
|
|
||||||
"gitea.pr.approve",
|
|
||||||
"gitea.pr.merge",
|
|
||||||
"gitea.pr.request_changes",
|
|
||||||
],
|
|
||||||
"execution_profile": "mdcps-author",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestIssue714SessionContextImmutability(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self._dir = tempfile.TemporaryDirectory()
|
|
||||||
self.config_path = os.path.join(self._dir.name, "profiles.json")
|
|
||||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
|
||||||
fh.write(json.dumps(CONFIG_714))
|
|
||||||
self._remotes = patch.dict(
|
|
||||||
mcp_server.REMOTES,
|
|
||||||
{
|
|
||||||
"dadeschools": {
|
|
||||||
"host": "gitea.dadeschools.net",
|
|
||||||
"org": "913443",
|
|
||||||
"repo": "eAgenda",
|
|
||||||
},
|
|
||||||
"prgs": {
|
|
||||||
"host": "gitea.prgs.cc",
|
|
||||||
"org": "Scaled-Tech-Consulting",
|
|
||||||
"repo": "Gitea-Tools",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
clear=False,
|
|
||||||
)
|
|
||||||
self._remotes.start()
|
|
||||||
mcp_server._IDENTITY_CACHE.clear()
|
|
||||||
gitea_config._active_profile_override = None
|
|
||||||
mcp_server._MUTATION_AUTHORITY = None
|
|
||||||
self._env = {
|
|
||||||
"GITEA_MCP_CONFIG": self.config_path,
|
|
||||||
"GITEA_MCP_PROFILE": "mdcps-reviewer",
|
|
||||||
"GITEA_TOKEN_MDCPS_REVIEWER": "mdcps-reviewer-token",
|
|
||||||
"GITEA_TOKEN_MDCPS_AUTHOR": "mdcps-author-token",
|
|
||||||
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
|
|
||||||
}
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._remotes.stop()
|
|
||||||
mcp_server._IDENTITY_CACHE.clear()
|
|
||||||
gitea_config._active_profile_override = None
|
|
||||||
mcp_server._MUTATION_AUTHORITY = None
|
|
||||||
self._dir.cleanup()
|
|
||||||
|
|
||||||
def _api_side_effect(self, method, url, header):
|
|
||||||
# Token identity mapping for whoami endpoint
|
|
||||||
auth = str(header)
|
|
||||||
if "mdcps-reviewer-token" in auth or "mdcps-author-token" in auth:
|
|
||||||
login = "913443"
|
|
||||||
else:
|
|
||||||
login = "jcwalker3"
|
|
||||||
return {"login": login, "full_name": "Test", "id": 1, "email": "[email protected]"}
|
|
||||||
|
|
||||||
def test_pin_mdcps_reviewer_never_drifts_to_prgs_author(self):
|
|
||||||
"""Reproduce the incident: pin mdcps-reviewer then resolve comment_issue."""
|
|
||||||
with patch.dict(os.environ, self._env, clear=False):
|
|
||||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
|
||||||
act = mcp_server.gitea_activate_profile(
|
|
||||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
|
||||||
)
|
|
||||||
self.assertTrue(act["success"])
|
|
||||||
self.assertEqual(act["after_profile"], "mdcps-reviewer")
|
|
||||||
|
|
||||||
who1 = mcp_server.gitea_whoami(remote="dadeschools")
|
|
||||||
self.assertEqual(who1["profile"]["profile_name"], "mdcps-reviewer")
|
|
||||||
self.assertEqual(who1["username"], "913443")
|
|
||||||
|
|
||||||
# Capability that mdcps-reviewer lacks — must NOT switch to prgs-author
|
|
||||||
res = mcp_server.gitea_resolve_task_capability(
|
|
||||||
task="comment_issue", remote="dadeschools"
|
|
||||||
)
|
|
||||||
self.assertEqual(res["active_profile"], "mdcps-reviewer")
|
|
||||||
self.assertFalse(res["allowed_in_current_session"])
|
|
||||||
self.assertFalse(res.get("auto_profile_substitution", True))
|
|
||||||
self.assertNotIn("prgs-author", res["matching_configured_profile"])
|
|
||||||
self.assertIn("mdcps-author", res["matching_configured_profile"])
|
|
||||||
|
|
||||||
who2 = mcp_server.gitea_whoami(remote="dadeschools")
|
|
||||||
rt = mcp_server.gitea_get_runtime_context(remote="dadeschools")
|
|
||||||
self.assertEqual(who2["profile"]["profile_name"], "mdcps-reviewer")
|
|
||||||
self.assertEqual(rt["active_profile"], "mdcps-reviewer")
|
|
||||||
# Still pinned — never prgs-author
|
|
||||||
self.assertEqual(
|
|
||||||
gitea_config.selected_profile_name(), "mdcps-reviewer"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_repeated_calls_remain_on_mdcps_reviewer(self):
|
|
||||||
with patch.dict(os.environ, self._env, clear=False):
|
|
||||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
|
||||||
mcp_server.gitea_activate_profile(
|
|
||||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
|
||||||
)
|
|
||||||
for _ in range(5):
|
|
||||||
res = mcp_server.gitea_resolve_task_capability(
|
|
||||||
task="review_pr", remote="dadeschools"
|
|
||||||
)
|
|
||||||
self.assertEqual(res["active_profile"], "mdcps-reviewer")
|
|
||||||
who = mcp_server.gitea_whoami(remote="dadeschools")
|
|
||||||
self.assertEqual(who["profile"]["profile_name"], "mdcps-reviewer")
|
|
||||||
rt = mcp_server.gitea_get_runtime_context(remote="dadeschools")
|
|
||||||
self.assertEqual(rt["active_profile"], "mdcps-reviewer")
|
|
||||||
|
|
||||||
def test_dadeschools_request_cannot_resolve_through_prgs_author(self):
|
|
||||||
with patch.dict(os.environ, self._env, clear=False):
|
|
||||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
|
||||||
mcp_server.gitea_activate_profile(
|
|
||||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
|
||||||
)
|
|
||||||
res = mcp_server.gitea_resolve_task_capability(
|
|
||||||
task="comment_issue", remote="dadeschools"
|
|
||||||
)
|
|
||||||
self.assertNotEqual(res["active_profile"], "prgs-author")
|
|
||||||
self.assertNotIn("prgs-author", res["matching_configured_profile"])
|
|
||||||
# Gate must not silently switch either
|
|
||||||
blocked = mcp_server._profile_permission_block(
|
|
||||||
"gitea.issue.comment", remote="dadeschools"
|
|
||||||
)
|
|
||||||
self.assertIsNotNone(blocked)
|
|
||||||
self.assertFalse(blocked.get("success", True))
|
|
||||||
self.assertEqual(
|
|
||||||
gitea_config.selected_profile_name(), "mdcps-reviewer"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_unsupported_capability_fails_closed_structured(self):
|
|
||||||
with patch.dict(os.environ, self._env, clear=False):
|
|
||||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
|
||||||
mcp_server.gitea_activate_profile(
|
|
||||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
|
||||||
)
|
|
||||||
res = mcp_server.gitea_resolve_task_capability(
|
|
||||||
task="comment_issue", remote="dadeschools"
|
|
||||||
)
|
|
||||||
self.assertFalse(res["allowed_in_current_session"])
|
|
||||||
self.assertTrue(res["stop_required"])
|
|
||||||
self.assertIn("reason", res)
|
|
||||||
self.assertIn("exact_safe_next_action", res)
|
|
||||||
self.assertIn("activate_profile", res["exact_safe_next_action"])
|
|
||||||
# Unknown task still fail closed
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
mcp_server.gitea_resolve_task_capability(
|
|
||||||
task="reopen_issue", remote="dadeschools"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_identity_mismatch_blocks_mutation(self):
|
|
||||||
"""Profile expects 913443 but authenticated as jcwalker3."""
|
|
||||||
|
|
||||||
def wrong_identity(method, url, header):
|
|
||||||
return {
|
|
||||||
"login": "jcwalker3",
|
|
||||||
"full_name": "Wrong",
|
|
||||||
"id": 9,
|
|
||||||
"email": "[email protected]",
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch.dict(os.environ, self._env, clear=False):
|
|
||||||
with patch("mcp_server.api_request", side_effect=wrong_identity):
|
|
||||||
mcp_server.gitea_activate_profile(
|
|
||||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
|
||||||
)
|
|
||||||
blocked = mcp_server._session_context_mutation_block(
|
|
||||||
remote="dadeschools"
|
|
||||||
)
|
|
||||||
self.assertIsNotNone(blocked)
|
|
||||||
self.assertTrue(
|
|
||||||
any("identity mismatch" in r for r in blocked["reasons"])
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_repository_host_mismatch_blocks_mutation(self):
|
|
||||||
"""mdcps-reviewer cannot serve prgs remote."""
|
|
||||||
with patch.dict(os.environ, self._env, clear=False):
|
|
||||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
|
||||||
mcp_server.gitea_activate_profile(
|
|
||||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
|
||||||
)
|
|
||||||
blocked = mcp_server._session_context_mutation_block(remote="prgs")
|
|
||||||
self.assertIsNotNone(blocked)
|
|
||||||
self.assertTrue(
|
|
||||||
any("cross-host" in r for r in blocked["reasons"])
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_drift_cannot_reach_write(self):
|
|
||||||
"""Simulated mid-session profile override cannot pass permission gate."""
|
|
||||||
with patch.dict(os.environ, self._env, clear=False):
|
|
||||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
|
||||||
mcp_server.gitea_activate_profile(
|
|
||||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
|
||||||
)
|
|
||||||
# Bind session as mdcps-reviewer
|
|
||||||
self.assertEqual(
|
|
||||||
session_ctx.get_session_context()["profile_name"],
|
|
||||||
"mdcps-reviewer",
|
|
||||||
)
|
|
||||||
# Hostile override without activate_profile
|
|
||||||
gitea_config._active_profile_override = "prgs-author"
|
|
||||||
blocked = mcp_server._session_context_mutation_block(
|
|
||||||
remote="dadeschools"
|
|
||||||
)
|
|
||||||
self.assertIsNotNone(blocked)
|
|
||||||
self.assertTrue(any("drift" in r or "cross-host" in r for r in blocked["reasons"]))
|
|
||||||
|
|
||||||
def test_static_prgs_author_still_works(self):
|
|
||||||
env = {
|
|
||||||
"GITEA_MCP_CONFIG": self.config_path,
|
|
||||||
"GITEA_MCP_PROFILE": "prgs-author",
|
|
||||||
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
|
|
||||||
}
|
|
||||||
with patch.dict(os.environ, env, clear=False):
|
|
||||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
|
||||||
gitea_config._active_profile_override = None
|
|
||||||
res = mcp_server.gitea_resolve_task_capability(
|
|
||||||
task="create_issue", remote="prgs"
|
|
||||||
)
|
|
||||||
self.assertEqual(res["active_profile"], "prgs-author")
|
|
||||||
self.assertTrue(res["allowed_in_current_session"])
|
|
||||||
self.assertEqual(res["active_identity"], "jcwalker3")
|
|
||||||
|
|
||||||
def test_static_mdcps_author_still_works(self):
|
|
||||||
env = {
|
|
||||||
"GITEA_MCP_CONFIG": self.config_path,
|
|
||||||
"GITEA_MCP_PROFILE": "mdcps-author",
|
|
||||||
"GITEA_TOKEN_MDCPS_AUTHOR": "mdcps-author-token",
|
|
||||||
}
|
|
||||||
with patch.dict(os.environ, env, clear=False):
|
|
||||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
|
||||||
gitea_config._active_profile_override = None
|
|
||||||
res = mcp_server.gitea_resolve_task_capability(
|
|
||||||
task="comment_issue", remote="dadeschools"
|
|
||||||
)
|
|
||||||
self.assertEqual(res["active_profile"], "mdcps-author")
|
|
||||||
self.assertTrue(res["allowed_in_current_session"])
|
|
||||||
self.assertNotIn("prgs-author", res["matching_configured_profile"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestSessionContextBindingUnit(unittest.TestCase):
|
|
||||||
def test_profile_matches_remote_by_base_url(self):
|
|
||||||
remotes = {
|
|
||||||
"dadeschools": {"host": "gitea.dadeschools.net"},
|
|
||||||
"prgs": {"host": "gitea.prgs.cc"},
|
|
||||||
}
|
|
||||||
mdcps = {"base_url": "https://gitea.dadeschools.net", "context": "mdcps"}
|
|
||||||
prgs = {"base_url": "https://gitea.prgs.cc", "context": "prgs"}
|
|
||||||
self.assertTrue(
|
|
||||||
session_ctx.profile_matches_remote(mdcps, "dadeschools", remotes)
|
|
||||||
)
|
|
||||||
self.assertFalse(session_ctx.profile_matches_remote(mdcps, "prgs", remotes))
|
|
||||||
self.assertTrue(session_ctx.profile_matches_remote(prgs, "prgs", remotes))
|
|
||||||
|
|
||||||
def test_bind_and_detect_drift(self):
|
|
||||||
session_ctx.bind_session_context(
|
|
||||||
profile_name="mdcps-reviewer",
|
|
||||||
remote="dadeschools",
|
|
||||||
host="gitea.dadeschools.net",
|
|
||||||
identity="913443",
|
|
||||||
source="test",
|
|
||||||
)
|
|
||||||
ok = session_ctx.assess_session_context(
|
|
||||||
profile_name="mdcps-reviewer",
|
|
||||||
remote="dadeschools",
|
|
||||||
host="gitea.dadeschools.net",
|
|
||||||
identity="913443",
|
|
||||||
)
|
|
||||||
self.assertTrue(ok["proven"])
|
|
||||||
bad = session_ctx.assess_session_context(
|
|
||||||
profile_name="prgs-author",
|
|
||||||
remote="dadeschools",
|
|
||||||
host="gitea.dadeschools.net",
|
|
||||||
identity="jcwalker3",
|
|
||||||
)
|
|
||||||
self.assertTrue(bad["block"])
|
|
||||||
self.assertTrue(any("drift" in r for r in bad["reasons"]))
|
|
||||||
|
|
||||||
def test_bound_context_survives_multiple_calls_and_exceptions(self):
|
|
||||||
original = session_ctx.bind_session_context(
|
|
||||||
profile_name="mdcps-reviewer",
|
|
||||||
remote="dadeschools",
|
|
||||||
host="gitea.dadeschools.net",
|
|
||||||
identity="913443",
|
|
||||||
source="test",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
for _ in range(3):
|
|
||||||
observed = session_ctx.seed_session_context_if_unbound(
|
|
||||||
profile_name="prgs-author",
|
|
||||||
remote="prgs",
|
|
||||||
host="gitea.prgs.cc",
|
|
||||||
identity="jcwalker3",
|
|
||||||
source="interleaved-test-call",
|
|
||||||
)
|
|
||||||
self.assertEqual(observed, original)
|
|
||||||
raise RuntimeError("simulated caller failure")
|
|
||||||
except RuntimeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
self.assertEqual(session_ctx.get_session_context(), original)
|
|
||||||
drift = session_ctx.assess_session_context(
|
|
||||||
profile_name="prgs-author",
|
|
||||||
remote="prgs",
|
|
||||||
host="gitea.prgs.cc",
|
|
||||||
identity="jcwalker3",
|
|
||||||
require_bound=True,
|
|
||||||
)
|
|
||||||
self.assertTrue(drift["block"])
|
|
||||||
|
|
||||||
def test_parallel_calls_cannot_overwrite_established_binding(self):
|
|
||||||
original = session_ctx.bind_session_context(
|
|
||||||
profile_name="mdcps-reviewer",
|
|
||||||
remote="dadeschools",
|
|
||||||
host="gitea.dadeschools.net",
|
|
||||||
identity="913443",
|
|
||||||
source="test",
|
|
||||||
)
|
|
||||||
barrier = threading.Barrier(9)
|
|
||||||
results = []
|
|
||||||
results_lock = threading.Lock()
|
|
||||||
|
|
||||||
def competing_seed(index: int) -> None:
|
|
||||||
barrier.wait()
|
|
||||||
result = session_ctx.seed_session_context_if_unbound(
|
|
||||||
profile_name=f"prgs-author-{index}",
|
|
||||||
remote="prgs",
|
|
||||||
host="gitea.prgs.cc",
|
|
||||||
identity=f"other-{index}",
|
|
||||||
source="parallel-test-call",
|
|
||||||
)
|
|
||||||
with results_lock:
|
|
||||||
results.append(result)
|
|
||||||
|
|
||||||
threads = [threading.Thread(target=competing_seed, args=(i,)) for i in range(8)]
|
|
||||||
for thread in threads:
|
|
||||||
thread.start()
|
|
||||||
barrier.wait()
|
|
||||||
for thread in threads:
|
|
||||||
thread.join(timeout=5)
|
|
||||||
|
|
||||||
self.assertFalse(any(thread.is_alive() for thread in threads))
|
|
||||||
self.assertEqual(results, [original] * 8)
|
|
||||||
self.assertEqual(session_ctx.get_session_context(), original)
|
|
||||||
|
|
||||||
def test_returned_snapshot_cannot_mutate_binding(self):
|
|
||||||
session_ctx.bind_session_context(
|
|
||||||
profile_name="mdcps-reviewer",
|
|
||||||
remote="dadeschools",
|
|
||||||
host="gitea.dadeschools.net",
|
|
||||||
identity="913443",
|
|
||||||
source="test",
|
|
||||||
)
|
|
||||||
snapshot = session_ctx.get_session_context()
|
|
||||||
snapshot["profile_name"] = "prgs-author"
|
|
||||||
self.assertEqual(
|
|
||||||
session_ctx.get_session_context()["profile_name"], "mdcps-reviewer"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSessionContextTestBoundaryIsolation(unittest.TestCase):
|
|
||||||
def test_mdcps_binding_starts_clean_and_does_not_escape_test(self):
|
|
||||||
self.assertIsNone(session_ctx.get_session_context())
|
|
||||||
session_ctx.bind_session_context(
|
|
||||||
profile_name="mdcps-reviewer",
|
|
||||||
remote="dadeschools",
|
|
||||||
host="gitea.dadeschools.net",
|
|
||||||
identity="913443",
|
|
||||||
source="test-boundary",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_prgs_binding_starts_clean_and_does_not_escape_test(self):
|
|
||||||
self.assertIsNone(session_ctx.get_session_context())
|
|
||||||
session_ctx.bind_session_context(
|
|
||||||
profile_name="prgs-author",
|
|
||||||
remote="prgs",
|
|
||||||
host="gitea.prgs.cc",
|
|
||||||
identity="jcwalker3",
|
|
||||||
source="test-boundary",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_reset_helper_is_rejected_outside_pytest_boundary(self):
|
|
||||||
with patch.dict(os.environ, {}, clear=True):
|
|
||||||
with self.assertRaises(RuntimeError):
|
|
||||||
session_ctx._reset_session_context_for_testing()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,9 +1,4 @@
|
|||||||
"""Tests for operation-scoped role selection (#228, updated by #714).
|
"""Tests for operation-scoped role selection and automatic dispatch switching (#228)."""
|
||||||
|
|
||||||
#714 removes silent automatic profile substitution. Capability resolution
|
|
||||||
and mutation gates evaluate only the active profile; explicit
|
|
||||||
``gitea_activate_profile`` is required to switch roles.
|
|
||||||
"""
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
@@ -20,7 +15,6 @@ from reviewer_worktree import assess_author_worktree_continuity
|
|||||||
|
|
||||||
CONFIG_TEST = {
|
CONFIG_TEST = {
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"allow_runtime_switching": True,
|
|
||||||
"contexts": {
|
"contexts": {
|
||||||
"ctx": {
|
"ctx": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -36,7 +30,6 @@ CONFIG_TEST = {
|
|||||||
"context": "ctx",
|
"context": "ctx",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
"username": "author-user",
|
"username": "author-user",
|
||||||
"base_url": "https://gitea.example.com",
|
|
||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||||
"allowed_operations": ["gitea.read", "gitea.issue.create", "gitea.pr.create", "gitea.branch.push", "gitea.issue.comment"],
|
"allowed_operations": ["gitea.read", "gitea.issue.create", "gitea.pr.create", "gitea.branch.push", "gitea.issue.comment"],
|
||||||
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||||
@@ -47,7 +40,6 @@ CONFIG_TEST = {
|
|||||||
"context": "ctx",
|
"context": "ctx",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
"username": "reviewer-user",
|
"username": "reviewer-user",
|
||||||
"base_url": "https://gitea.example.com",
|
|
||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
||||||
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.merge", "gitea.issue.comment"],
|
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.merge", "gitea.issue.comment"],
|
||||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
||||||
@@ -93,71 +85,62 @@ class TestOperationScopedRoles(unittest.TestCase):
|
|||||||
return env
|
return env
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
def test_no_auto_switch_to_reviewer(self, mock_api):
|
def test_auto_switch_to_reviewer(self, mock_api):
|
||||||
# #714: capability resolution must not silently switch author → reviewer
|
# mock identity resolution to return username matching profile
|
||||||
mock_api.side_effect = lambda method, url, header: (
|
mock_api.side_effect = lambda method, url, header: (
|
||||||
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
||||||
)
|
)
|
||||||
with patch.dict(os.environ, self._env("author-profile")):
|
with patch.dict(os.environ, self._env("author-profile")):
|
||||||
|
# initially we are author-profile
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
||||||
self.assertEqual(mcp_server.get_profile()["profile_name"], "author-profile")
|
self.assertEqual(mcp_server.get_profile()["profile_name"], "author-profile")
|
||||||
|
|
||||||
|
# resolve a reviewer task (review_pr)
|
||||||
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
||||||
|
|
||||||
self.assertFalse(res["allowed_in_current_session"])
|
# verify it automatically switched to reviewer-profile
|
||||||
self.assertFalse(res["available_in_session"])
|
self.assertTrue(res["allowed_in_current_session"])
|
||||||
self.assertEqual(res["active_profile"], "author-profile")
|
self.assertTrue(res["available_in_session"])
|
||||||
self.assertEqual(res["active_identity"], "author-user")
|
self.assertEqual(res["active_profile"], "reviewer-profile")
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
self.assertEqual(res["active_identity"], "reviewer-user")
|
||||||
self.assertIn("reviewer-profile", res["matching_configured_profile"])
|
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
|
||||||
self.assertFalse(res.get("auto_profile_substitution", True))
|
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
def test_no_auto_switch_to_author(self, mock_api):
|
def test_auto_switch_to_author(self, mock_api):
|
||||||
mock_api.side_effect = lambda method, url, header: (
|
mock_api.side_effect = lambda method, url, header: (
|
||||||
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
||||||
)
|
)
|
||||||
with patch.dict(os.environ, self._env("reviewer-profile")):
|
with patch.dict(os.environ, self._env("reviewer-profile")):
|
||||||
|
# initially we are reviewer-profile
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
|
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
|
||||||
|
|
||||||
|
# resolve an author task (create_issue)
|
||||||
res = mcp_server.gitea_resolve_task_capability(task="create_issue", remote="prgs")
|
res = mcp_server.gitea_resolve_task_capability(task="create_issue", remote="prgs")
|
||||||
|
|
||||||
self.assertFalse(res["allowed_in_current_session"])
|
# verify it automatically switched to author-profile
|
||||||
self.assertFalse(res["available_in_session"])
|
|
||||||
self.assertEqual(res["active_profile"], "reviewer-profile")
|
|
||||||
self.assertEqual(res["active_identity"], "reviewer-user")
|
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
|
|
||||||
self.assertIn("author-profile", res["matching_configured_profile"])
|
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
|
||||||
def test_explicit_activate_profile_still_works(self, mock_api):
|
|
||||||
mock_api.side_effect = lambda method, url, header: (
|
|
||||||
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
|
||||||
)
|
|
||||||
with patch.dict(os.environ, self._env("author-profile")):
|
|
||||||
act = mcp_server.gitea_activate_profile(
|
|
||||||
profile_name="reviewer-profile", remote="prgs"
|
|
||||||
)
|
|
||||||
self.assertTrue(act["success"])
|
|
||||||
self.assertEqual(act["after_profile"], "reviewer-profile")
|
|
||||||
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
|
||||||
self.assertTrue(res["allowed_in_current_session"])
|
self.assertTrue(res["allowed_in_current_session"])
|
||||||
self.assertEqual(res["active_profile"], "reviewer-profile")
|
self.assertTrue(res["available_in_session"])
|
||||||
|
self.assertEqual(res["active_profile"], "author-profile")
|
||||||
|
self.assertEqual(res["active_identity"], "author-user")
|
||||||
|
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
def test_denied_when_matching_profile_token_missing(self, mock_api):
|
def test_restart_required_when_unattached(self, mock_api):
|
||||||
mock_api.side_effect = lambda method, url, header: {"login": "author-user"}
|
mock_api.side_effect = lambda method, url, header: {"login": "author-user"}
|
||||||
# launch without reviewer token in env
|
# launch without reviewer token in env
|
||||||
with patch.dict(os.environ, self._env("author-profile", with_reviewer_token=False)):
|
with patch.dict(os.environ, self._env("author-profile", with_reviewer_token=False)):
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
||||||
|
|
||||||
|
# resolve a reviewer task (review_pr)
|
||||||
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
||||||
|
|
||||||
|
# verify it did NOT switch and reports restart_required
|
||||||
self.assertFalse(res["allowed_in_current_session"])
|
self.assertFalse(res["allowed_in_current_session"])
|
||||||
self.assertFalse(res["available_in_session"])
|
self.assertFalse(res["available_in_session"])
|
||||||
self.assertTrue(res["configured"])
|
self.assertTrue(res["configured"])
|
||||||
|
self.assertTrue(res["restart_required"])
|
||||||
self.assertTrue(res["stop_required"])
|
self.assertTrue(res["stop_required"])
|
||||||
self.assertEqual(res["active_profile"], "author-profile")
|
self.assertIn("Reviewer profile exists but MCP server was added after session startup and is not attached", res["reason"])
|
||||||
|
|
||||||
def test_author_continuity_dirty_worktree(self):
|
def test_author_continuity_dirty_worktree(self):
|
||||||
# author is allowed to keep dirty worktree
|
# author is allowed to keep dirty worktree
|
||||||
@@ -178,21 +161,20 @@ class TestOperationScopedRoles(unittest.TestCase):
|
|||||||
self.assertFalse(res2["proven"])
|
self.assertFalse(res2["proven"])
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
def test_mutating_actions_do_not_auto_switch(self, mock_api):
|
def test_mutating_actions_auto_switch(self, mock_api):
|
||||||
mock_api.side_effect = lambda method, url, header: (
|
mock_api.side_effect = lambda method, url, header: (
|
||||||
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
||||||
)
|
)
|
||||||
with patch.dict(os.environ, self._env("author-profile")):
|
with patch.dict(os.environ, self._env("author-profile")):
|
||||||
|
# verify we are author-profile
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
||||||
|
|
||||||
blocked = mcp_server._profile_permission_block(
|
# call a reviewer mutation check helper (like _profile_permission_block with reviewer permission)
|
||||||
"gitea.pr.merge", remote="prgs"
|
blocked = mcp_server._profile_permission_block("gitea.pr.merge", remote="prgs")
|
||||||
)
|
self.assertIsNone(blocked) # should switch to reviewer-profile and allow it (no permission block)
|
||||||
self.assertIsNotNone(blocked)
|
|
||||||
self.assertFalse(blocked.get("success", True))
|
|
||||||
|
|
||||||
# profile must remain author — no silent substitution
|
# verify we dynamically switched to reviewer-profile
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -188,8 +188,8 @@ class TestResolveTaskCapability(unittest.TestCase):
|
|||||||
self.assertEqual(res["required_role_kind"], "author")
|
self.assertEqual(res["required_role_kind"], "author")
|
||||||
self.assertTrue(res["allowed_in_current_session"])
|
self.assertTrue(res["allowed_in_current_session"])
|
||||||
|
|
||||||
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
|
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||||
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
def test_resolve_work_issue_reviewer_profile_blocked(self, _auth, _api):
|
def test_resolve_work_issue_reviewer_profile_blocked(self, _auth, _api):
|
||||||
with patch.dict(os.environ, self._env("reviewer-profile")):
|
with patch.dict(os.environ, self._env("reviewer-profile")):
|
||||||
res = mcp_server.gitea_resolve_task_capability(
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
|||||||
Reference in New Issue
Block a user