feat: persist MCP workflow proof and decision lock across daemons (Closes #559)
Share review-workflow-load and review-decision-lock state across MCP daemon process pools via a user-private durable store keyed by profile identity (not host-global /tmp), with TTL and fail-closed profile checks.
This commit is contained in:
+94
-15
@@ -804,6 +804,7 @@ import task_capability_map # noqa: E402
|
||||
import review_proofs # noqa: E402
|
||||
import review_workflow_boundary # noqa: E402
|
||||
import review_workflow_load # noqa: E402
|
||||
import mcp_session_state # noqa: E402
|
||||
import agent_temp_artifacts
|
||||
import issue_lock_worktree # noqa: E402
|
||||
import issue_lock_provenance # noqa: E402
|
||||
@@ -2480,37 +2481,110 @@ _REVIEW_ACTIONS = {
|
||||
|
||||
_TERMINAL_REVIEW_ACTIONS = frozenset({"approve", "request_changes"})
|
||||
|
||||
# In-process only (#211): never persist to /tmp — host-global files are
|
||||
# spoofable by any local process and go stale across sessions.
|
||||
# Durable across MCP daemon process pools (#559), but never under /tmp (#211):
|
||||
# host-global temp files are spoofable. Persistence uses the user-private
|
||||
# cache directory from mcp_session_state (mode 0o700 / files 0o600), keyed by
|
||||
# remote + profile identity with TTL.
|
||||
_REVIEW_DECISION_LOCK: dict | None = None
|
||||
|
||||
|
||||
def _decision_lock_binding(lock: dict | None = None) -> dict:
|
||||
"""Resolve key fields for durable decision-lock storage."""
|
||||
profile = get_profile()
|
||||
profile_name = (profile.get("profile_name") or "").strip()
|
||||
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||||
stored_lock = ((lock or {}).get("session_profile_lock") or "").strip()
|
||||
session_lock = env_lock or stored_lock or profile_name
|
||||
remote = ((lock or {}).get("remote") or "").strip() or None
|
||||
org = ((lock or {}).get("org") or (lock or {}).get("ready_org") or "").strip() or None
|
||||
repo = ((lock or {}).get("repo") or (lock or {}).get("ready_repo") or "").strip() or None
|
||||
return {
|
||||
"session_profile": profile_name,
|
||||
"session_profile_lock": session_lock,
|
||||
"profile_identity": mcp_session_state.current_profile_identity(
|
||||
profile_name=profile_name,
|
||||
session_profile_lock=session_lock,
|
||||
),
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
}
|
||||
|
||||
|
||||
def _load_review_decision_lock():
|
||||
"""Load decision lock from memory, falling back to durable session state."""
|
||||
global _REVIEW_DECISION_LOCK
|
||||
if _REVIEW_DECISION_LOCK is not None:
|
||||
return _REVIEW_DECISION_LOCK
|
||||
binding = _decision_lock_binding()
|
||||
# Profile-keyed durable file; remote/org/repo validated from payload (#559).
|
||||
durable = mcp_session_state.load_state(
|
||||
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||||
profile_identity=binding.get("profile_identity"),
|
||||
)
|
||||
if durable is not None:
|
||||
_REVIEW_DECISION_LOCK = dict(durable)
|
||||
return _REVIEW_DECISION_LOCK
|
||||
|
||||
|
||||
def _save_review_decision_lock(data):
|
||||
"""Persist decision lock to memory + durable shared state (#559)."""
|
||||
global _REVIEW_DECISION_LOCK
|
||||
_REVIEW_DECISION_LOCK = data
|
||||
if data is None:
|
||||
binding = _decision_lock_binding(_REVIEW_DECISION_LOCK)
|
||||
mcp_session_state.clear_state(
|
||||
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||||
profile_identity=binding.get("profile_identity"),
|
||||
)
|
||||
_REVIEW_DECISION_LOCK = None
|
||||
return
|
||||
payload = dict(data)
|
||||
binding = _decision_lock_binding(payload)
|
||||
payload.setdefault("session_pid", os.getpid())
|
||||
payload["writer_pid"] = os.getpid()
|
||||
payload["session_profile"] = binding["session_profile"] or payload.get(
|
||||
"session_profile"
|
||||
)
|
||||
payload["session_profile_lock"] = binding["session_profile_lock"]
|
||||
payload["profile_identity"] = binding["profile_identity"]
|
||||
if binding.get("remote") and not payload.get("remote"):
|
||||
payload["remote"] = binding["remote"]
|
||||
persisted = mcp_session_state.save_state(
|
||||
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||||
payload=payload,
|
||||
remote=payload.get("remote"),
|
||||
org=payload.get("org") or payload.get("ready_org"),
|
||||
repo=payload.get("repo") or payload.get("ready_repo"),
|
||||
profile_identity=payload.get("profile_identity"),
|
||||
)
|
||||
_REVIEW_DECISION_LOCK = dict(persisted or payload)
|
||||
|
||||
|
||||
def _review_decision_session_reasons(lock: dict | None) -> list[str]:
|
||||
"""Reject locks that do not belong to this MCP process/session."""
|
||||
"""Reject locks that do not belong to this MCP session identity (#559).
|
||||
|
||||
Different daemon PIDs in the same IDE session pool are allowed when the
|
||||
profile identity and remote match. Spoofed/stale locks still fail closed.
|
||||
"""
|
||||
if lock is None:
|
||||
return []
|
||||
reasons = []
|
||||
if lock.get("session_pid") != os.getpid():
|
||||
reasons.append(
|
||||
"review decision lock was created in a different process "
|
||||
"(fail closed)"
|
||||
)
|
||||
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||||
stored_lock = (lock.get("session_profile_lock") or "").strip()
|
||||
stored_lock = (lock.get("session_profile_lock") or lock.get("profile_identity") or "").strip()
|
||||
if env_lock and stored_lock and env_lock != stored_lock:
|
||||
reasons.append(
|
||||
"review decision lock session profile lock mismatch (fail closed)"
|
||||
)
|
||||
# TTL / identity checks from durable envelope fields.
|
||||
for reason in mcp_session_state.identity_match_reasons(
|
||||
lock,
|
||||
remote=lock.get("remote"),
|
||||
org=lock.get("org") or lock.get("ready_org"),
|
||||
repo=lock.get("repo") or lock.get("ready_repo"),
|
||||
profile_identity=env_lock or stored_lock,
|
||||
):
|
||||
if "profile identity mismatch" in reason or "expired" in reason or "future" in reason or "missing recorded_at" in reason:
|
||||
reasons.append(reason)
|
||||
return reasons
|
||||
|
||||
|
||||
@@ -2521,11 +2595,12 @@ def init_review_decision_lock(remote: str | None, task: str | None, force: bool
|
||||
if not force:
|
||||
lock = _load_review_decision_lock()
|
||||
if lock is not None:
|
||||
if lock.get("remote") == remote and lock.get("session_pid") == os.getpid():
|
||||
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||||
stored_lock = (lock.get("session_profile_lock") or "").strip()
|
||||
if not env_lock or not stored_lock or env_lock == stored_lock:
|
||||
return
|
||||
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||||
stored_lock = (lock.get("session_profile_lock") or "").strip()
|
||||
same_remote = lock.get("remote") == remote
|
||||
same_profile = (not env_lock or not stored_lock or env_lock == stored_lock)
|
||||
if same_remote and same_profile and not _review_decision_session_reasons(lock):
|
||||
return
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
profile = get_profile()
|
||||
profile_name = (profile.get("profile_name") or "").strip()
|
||||
@@ -2540,6 +2615,10 @@ def init_review_decision_lock(remote: str | None, task: str | None, force: bool
|
||||
"session_pid": os.getpid(),
|
||||
"session_profile": profile_name,
|
||||
"session_profile_lock": session_lock,
|
||||
"profile_identity": mcp_session_state.current_profile_identity(
|
||||
profile_name=profile_name,
|
||||
session_profile_lock=session_lock,
|
||||
),
|
||||
"final_review_decision_ready": False,
|
||||
"ready_pr_number": None,
|
||||
"ready_action": None,
|
||||
|
||||
Reference in New Issue
Block a user