Compare commits

..
8 changed files with 242 additions and 459 deletions
+62 -4
View File
@@ -22,8 +22,62 @@ import gitea_config
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
# Load standard .env if present # Reserved runtime-control / workspace-binding environment variables (#704).
load_dotenv(os.path.join(PROJECT_ROOT, ".env")) # Repository .env files MUST NOT populate or override any of these keys.
RESERVED_WORKTREE_ENV_KEYS: frozenset[str] = frozenset({
"GITEA_ACTIVE_WORKTREE",
"GITEA_AUTHOR_WORKTREE",
"GITEA_REVIEWER_WORKTREE",
"GITEA_MERGER_WORKTREE",
"GITEA_RECONCILER_WORKTREE",
})
def is_reserved_worktree_env_key(key: str | None) -> bool:
"""Return True if *key* is a reserved runtime workspace binding variable (#704)."""
if not key:
return False
k = str(key).upper().strip()
return k in RESERVED_WORKTREE_ENV_KEYS or (k.startswith("GITEA_") and k.endswith("_WORKTREE"))
def load_env_file_sanitized(
env_path: str,
*,
target_env: dict | os._Environ | None = None,
) -> list[str]:
"""Load a .env file without populating or overriding reserved workspace keys (#704).
Pre-existing process environment values retain their precedence. Reserved
runtime-control keys found in repository files are ignored (without logging
their values). Returns a list of sanitized rejection reasons.
"""
if target_env is None:
target_env = os.environ
if not os.path.exists(env_path) or os.path.isdir(env_path):
return []
rejection_reasons: list[str] = []
try:
file_vals = dotenv_values(env_path)
for key, val in file_vals.items():
if not key or val is None:
continue
if is_reserved_worktree_env_key(key):
filename = os.path.basename(env_path)
rejection_reasons.append(
f"Ignored reserved runtime workspace key '{key}' from repository {filename}"
)
continue
if key not in target_env:
target_env[key] = val
except Exception:
pass
return rejection_reasons
# Load standard .env if present (sanitized to prevent repo workspace binding contamination #704)
load_env_file_sanitized(os.path.join(PROJECT_ROOT, ".env"))
# Dictionary to store configurations parsed dynamically from .env.* files # Dictionary to store configurations parsed dynamically from .env.* files
DYNAMIC_CONFIGS = {} DYNAMIC_CONFIGS = {}
@@ -37,9 +91,13 @@ for env_path in glob.glob(os.path.join(PROJECT_ROOT, ".env*")):
continue continue
try: try:
config_vals = dotenv_values(env_path) config_vals = dotenv_values(env_path)
site = config_vals.get("GITEA_SITE") or config_vals.get("GITEA_HOST") # Filter out reserved workspace keys from dynamic configs (#704)
sanitized_config = {
k: v for k, v in config_vals.items() if not is_reserved_worktree_env_key(k)
}
site = sanitized_config.get("GITEA_SITE") or sanitized_config.get("GITEA_HOST")
if site: if site:
DYNAMIC_CONFIGS[site.lower().strip()] = config_vals DYNAMIC_CONFIGS[site.lower().strip()] = sanitized_config
except Exception: except Exception:
pass pass
+1 -15
View File
@@ -24,8 +24,6 @@ import subprocess
import uuid import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
import hashlib
@@ -2260,12 +2258,7 @@ def _seed_session_context(
expected_username=expected, expected_username=expected,
source=source, source=source,
canonical_repository_root=canonical_root_pin, canonical_repository_root=canonical_root_pin,
cohort_id=_COHORT_ID,
startup_sha=_STARTUP_PARITY.get("startup_head"),
endpoint=host or (profile.get("base_url") or "").strip() or None,
config_fingerprint=_CONFIG_FINGERPRINT,
) )
import issue_work_duplicate_gate # noqa: E402 import issue_work_duplicate_gate # noqa: E402
import issue_workflow_labels # noqa: E402 import issue_workflow_labels # noqa: E402
import terminal_pr_label_cleanup # noqa: E402 # #780 status:pr-open terminal rule import terminal_pr_label_cleanup # noqa: E402 # #780 status:pr-open terminal rule
@@ -2294,11 +2287,6 @@ import stable_control_runtime # noqa: E402
# master has advanced past the running code and fail closed until restart. # master has advanced past the running code and fail closed until restart.
# Read-only operations are never blocked by staleness. # Read-only operations are never blocked by staleness.
_STARTUP_PARITY = master_parity_gate.capture_startup_parity(PROJECT_ROOT) _STARTUP_PARITY = master_parity_gate.capture_startup_parity(PROJECT_ROOT)
_COHORT_ID: str = f"cohort-p{os.getpid()}-{_STARTUP_PARITY.get('startup_head') or 'unknown'}"
_CONFIG_FINGERPRINT: str = hashlib.sha256(
(PROJECT_ROOT + str(_STARTUP_PARITY.get("startup_head"))).encode("utf-8")
).hexdigest()[:16]
# Stable-control runtime facts (#615): which runtime this process serves from. # Stable-control runtime facts (#615): which runtime this process serves from.
# These are the *immutable* facts -- process root, branch, head, checkout-ness -- # These are the *immutable* facts -- process root, branch, head, checkout-ness --
@@ -14209,10 +14197,8 @@ def _current_master_parity() -> dict:
current_head = master_parity_gate.read_git_head(PROJECT_ROOT) current_head = master_parity_gate.read_git_head(PROJECT_ROOT)
live_head = master_parity_gate.read_remote_master_head( live_head = master_parity_gate.read_remote_master_head(
PROJECT_ROOT, remote=_git_default_remote_name(PROJECT_ROOT)) PROJECT_ROOT, remote=_git_default_remote_name(PROJECT_ROOT))
bound_context = session_ctx.get_session_context()
return master_parity_gate.assess_master_parity( return master_parity_gate.assess_master_parity(
_STARTUP_PARITY, current_head, live_remote_head=live_head, bound_cohort=bound_context) _STARTUP_PARITY, current_head, live_remote_head=live_head)
def _current_runtime_mode_report(refresh: bool = False) -> dict: def _current_runtime_mode_report(refresh: bool = False) -> dict:
+9 -70
View File
@@ -183,7 +183,6 @@ def assess_master_parity(
startup: dict | None, startup: dict | None,
current_head: str | None, current_head: str | None,
live_remote_head: str | None = None, live_remote_head: str | None = None,
bound_cohort: dict | None = None,
) -> dict: ) -> dict:
"""Compare the startup baseline against the current on-disk ``HEAD``. """Compare the startup baseline against the current on-disk ``HEAD``.
@@ -193,8 +192,7 @@ def assess_master_parity(
could not be determined, which is not treated as stale). could not be determined, which is not treated as stale).
- ``stale`` -- the on-disk master has definitively advanced past the - ``stale`` -- the on-disk master has definitively advanced past the
running process. running process.
- ``restart_required`` -- ``stale``, ``live_stale``, or ``cohort_stale``; the - ``restart_required`` -- ``stale`` or ``live_stale``; the recovery action.
recovery action.
- ``determinable`` -- whether both local HEADs were known well enough to - ``determinable`` -- whether both local HEADs were known well enough to
compare. compare.
- ``startup_head`` / ``current_head`` / ``reasons``. - ``startup_head`` / ``current_head`` / ``reasons``.
@@ -211,69 +209,24 @@ def assess_master_parity(
- ``live_known`` -- whether the live remote target was resolved. - ``live_known`` -- whether the live remote target was resolved.
- ``live_stale`` -- the live remote master has advanced past the running - ``live_stale`` -- the live remote master has advanced past the running
process (daemon is behind live master) even if local parity is green. process (daemon is behind live master) even if local parity is green.
- ``bound_cohort`` -- metadata describing the bound MCP cohort. - ``mutation_safe`` -- the daemon code, local checkout, and live remote
- ``cohort_parity_match`` -- whether bound cohort startup SHA matches parity. target all agree; the only state in which a mutation may rely on parity.
- ``cohort_stale`` -- bound cohort startup SHA is stale relative to parity.
- ``mutation_safe`` -- the daemon code, local checkout, live remote target,
and bound cohort all agree; the only state in which a mutation may rely
on parity.
""" """
startup_head = (startup or {}).get("startup_head") startup_head = (startup or {}).get("startup_head")
reasons: list[str] = [] reasons: list[str] = []
cohort_info: dict | None = None
cohort_parity_match = True
cohort_stale = False
if bound_cohort:
c_id = str(bound_cohort.get("cohort_id") or "").strip() or None
c_pid = bound_cohort.get("pid")
c_sha = str(
bound_cohort.get("startup_sha")
or bound_cohort.get("git_head")
or ""
).strip() or None
c_endpoint = str(bound_cohort.get("endpoint") or "").strip() or None
c_fingerprint = str(
bound_cohort.get("config_fingerprint") or ""
).strip() or None
cohort_info = {
"cohort_id": c_id,
"pid": c_pid,
"startup_sha": c_sha,
"endpoint": c_endpoint,
"config_fingerprint": c_fingerprint,
}
parity_ref = live_remote_head or current_head or startup_head
if c_sha and parity_ref:
if c_sha.lower() != parity_ref.lower():
cohort_parity_match = False
cohort_stale = True
reasons.append(
f"bound cohort startup SHA '{_short(c_sha)}' does not "
f"match authoritative parity SHA '{_short(parity_ref)}' "
"(stale cohort refused)"
)
if startup_head is None: if startup_head is None:
reasons.append( reasons.append(
"startup commit was not captured; code parity cannot be enforced") "startup commit was not captured; code parity cannot be enforced")
return _result(True, False, False, startup_head, current_head, return _result(True, False, False, startup_head, current_head,
live_remote_head, False, reasons, live_remote_head, False, reasons)
bound_cohort=cohort_info,
cohort_parity_match=cohort_parity_match,
cohort_stale=cohort_stale)
if current_head is None: if current_head is None:
reasons.append( reasons.append(
"current workspace HEAD could not be read; code parity cannot be " "current workspace HEAD could not be read; code parity cannot be "
"enforced") "enforced")
return _result(True, False, False, startup_head, current_head, return _result(True, False, False, startup_head, current_head,
live_remote_head, False, reasons, live_remote_head, False, reasons)
bound_cohort=cohort_info,
cohort_parity_match=cohort_parity_match,
cohort_stale=cohort_stale)
local_in_parity = startup_head == current_head local_in_parity = startup_head == current_head
local_stale = not local_in_parity local_stale = not local_in_parity
@@ -293,28 +246,18 @@ def assess_master_parity(
return _result( return _result(
local_in_parity, local_stale, True, startup_head, current_head, local_in_parity, local_stale, True, startup_head, current_head,
live_remote_head, live_stale, reasons, live_remote_head, live_stale, reasons)
bound_cohort=cohort_info,
cohort_parity_match=cohort_parity_match,
cohort_stale=cohort_stale)
def _result(in_parity, stale, determinable, startup_head, current_head, def _result(in_parity, stale, determinable, startup_head, current_head,
live_remote_head, live_stale, reasons, bound_cohort=None, live_remote_head, live_stale, reasons):
cohort_parity_match=True, cohort_stale=False):
live_known = live_remote_head is not None live_known = live_remote_head is not None
mutation_safe = ( mutation_safe = (
determinable determinable and in_parity and live_known and not live_stale)
and in_parity
and live_known
and not live_stale
and cohort_parity_match
and not cohort_stale
)
return { return {
"in_parity": in_parity, "in_parity": in_parity,
"stale": stale, "stale": stale,
"restart_required": stale or live_stale or cohort_stale, "restart_required": stale or live_stale,
"determinable": determinable, "determinable": determinable,
"startup_head": startup_head, "startup_head": startup_head,
"current_head": current_head, "current_head": current_head,
@@ -324,15 +267,11 @@ def _result(in_parity, stale, determinable, startup_head, current_head,
"live_remote_head": live_remote_head, "live_remote_head": live_remote_head,
"live_known": live_known, "live_known": live_known,
"live_stale": live_stale, "live_stale": live_stale,
"cohort_parity_match": cohort_parity_match,
"cohort_stale": cohort_stale,
"bound_cohort": bound_cohort,
"mutation_safe": mutation_safe, "mutation_safe": mutation_safe,
"reasons": list(reasons), "reasons": list(reasons),
} }
def gate_disabled() -> bool: def gate_disabled() -> bool:
"""Whether the parity gate is disabled by env escape hatch.""" """Whether the parity gate is disabled by env escape hatch."""
return bool((os.environ.get(ENV_DISABLE) or "").strip()) return bool((os.environ.get(ENV_DISABLE) or "").strip())
+3 -45
View File
@@ -104,8 +104,6 @@ def classify_namespace_probe(
profile: str | None = None, profile: str | None = None,
configured: bool = True, configured: bool = True,
probe_source: str | None = None, probe_source: str | None = None,
expected_parity_sha: str | None = None,
bound_cohort: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Classify whether a required tool is callable through a live namespace. """Classify whether a required tool is callable through a live namespace.
@@ -137,50 +135,20 @@ def classify_namespace_probe(
else: else:
error_type = "namespace_call_failed" error_type = "namespace_call_failed"
# Extract cohort metadata
cohort_meta = bound_cohort or probe.get("cohort") or probe.get("bound_cohort") or {}
cohort_id = str(
cohort_meta.get("cohort_id") or probe.get("cohort_id") or ""
).strip() or None
startup_sha = str(
cohort_meta.get("startup_sha")
or cohort_meta.get("git_head")
or probe.get("startup_sha")
or probe.get("git_head")
or ""
).strip() or None
endpoint = str(
cohort_meta.get("endpoint") or probe.get("endpoint") or ""
).strip() or None
config_fingerprint = str(
cohort_meta.get("config_fingerprint") or probe.get("config_fingerprint") or ""
).strip() or None
expected_sha = (expected_parity_sha or "").strip().lower() or None
stale_cohort = False
if expected_sha and startup_sha:
if startup_sha.lower() != expected_sha:
stale_cohort = True
error_type = "stale_cohort_refused"
if not configured: if not configured:
error_type = "namespace_not_configured" error_type = "namespace_not_configured"
elif registered is False: elif registered is False:
error_type = "tool_missing" error_type = "tool_missing"
elif not probe_result: elif not probe_result:
error_type = "live_probe_missing" error_type = "live_probe_missing"
elif stale_cohort:
error_type = "stale_cohort_refused"
elif not probe_success and not error_type: elif not probe_success and not error_type:
error_type = "namespace_call_failed" error_type = "namespace_call_failed"
callable_live = bool(configured and probe_result and probe_success and not stale_cohort) callable_live = bool(configured and probe_result and probe_success)
# Probe-path health (spawn or client). IDE-proven only for client path. # Probe-path health (spawn or client). IDE-proven only for client path.
healthy = bool(configured and registered is not False and callable_live) healthy = bool(configured and registered is not False and callable_live)
ide_namespace_proven = bool(healthy and source == PROBE_SOURCE_CLIENT) ide_namespace_proven = bool(healthy and source == PROBE_SOURCE_CLIENT)
process_pid = process.get("pid") if isinstance(process, dict) else ( process_pid = process.get("pid") if isinstance(process, dict) else None
cohort_meta.get("pid") if isinstance(cohort_meta, dict) else None
)
profile_name = profile or ( profile_name = profile or (
process.get("profile") if isinstance(process, dict) else None process.get("profile") if isinstance(process, dict) else None
) )
@@ -193,13 +161,7 @@ def classify_namespace_probe(
reasons.append( reasons.append(
f"Required tool '{tool}' is not registered in namespace '{ns}'." f"Required tool '{tool}' is not registered in namespace '{ns}'."
) )
if error_type == "stale_cohort_refused": if error_type == "live_probe_missing":
reasons.append(
f"Bound cohort startup SHA '{startup_sha[:12] if startup_sha else 'unknown'}' "
f"does not match expected parity SHA '{expected_sha[:12] if expected_sha else 'unknown'}' "
"(stale cohort refused)."
)
elif error_type == "live_probe_missing":
reasons.append( reasons.append(
f"No live client invocation proof was supplied for '{ns}.{tool}'." f"No live client invocation proof was supplied for '{ns}.{tool}'."
) )
@@ -286,10 +248,6 @@ def classify_namespace_probe(
"env": env_summary, "env": env_summary,
"config_path": config_path, "config_path": config_path,
"probe_source": source, "probe_source": source,
"cohort_id": cohort_id,
"startup_sha": startup_sha,
"endpoint": endpoint,
"config_fingerprint": config_fingerprint,
}, },
"blocks_merge_workflow": blocks, "blocks_merge_workflow": blocks,
} }
+1 -22
View File
@@ -151,16 +151,12 @@ class RestartCompletionProof:
unresolved_count: int unresolved_count: int
skipped_count: int skipped_count: int
note: str note: str
binding_unchanged: bool = False
prior_reconcile_id: str | None = None
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
return { return {
"schema_version": self.schema_version, "schema_version": self.schema_version,
"reconcile_version": self.reconcile_version, "reconcile_version": self.reconcile_version,
"reconcile_id": self.reconcile_id, "reconcile_id": self.reconcile_id,
"binding_unchanged": self.binding_unchanged,
"prior_reconcile_id": self.prior_reconcile_id,
"started_at": self.started_at, "started_at": self.started_at,
"finished_at": self.finished_at, "finished_at": self.finished_at,
"boot_head_sha": self.boot_head_sha, "boot_head_sha": self.boot_head_sha,
@@ -177,7 +173,6 @@ class RestartCompletionProof:
"skipped_count": self.skipped_count, "skipped_count": self.skipped_count,
"note": self.note, "note": self.note,
"links": { "links": {
"umbrella": 655, "umbrella": 655,
"vision": 652, "vision": 652,
"roadmap": 653, "roadmap": 653,
@@ -364,9 +359,7 @@ def reconcile_after_restart(
now: datetime | None = None, now: datetime | None = None,
mode: str = MODE_LOG_ONLY, mode: str = MODE_LOG_ONLY,
reconcile_id: str | None = None, reconcile_id: str | None = None,
prior_reconcile_id: str | None = None,
) -> RestartCompletionProof: ) -> RestartCompletionProof:
"""Classify a post-restart inventory into a completion proof (#662). """Classify a post-restart inventory into a completion proof (#662).
Parameters Parameters
@@ -757,26 +750,12 @@ def reconcile_after_restart(
f"Mode={mode_norm}." f"Mode={mode_norm}."
) )
prior_id = str(
prior_reconcile_id
or inventory.get("prior_reconcile_id")
or ""
).strip() or None
target_rec_id = str(reconcile_id or "").strip() or None
binding_unchanged = bool(
prior_id and target_rec_id and target_rec_id == prior_id
)
final_reconcile_id = target_rec_id or f"reconcile-{uuid4().hex[:12]}"
return RestartCompletionProof( return RestartCompletionProof(
schema_version=SCHEMA_VERSION, schema_version=SCHEMA_VERSION,
reconcile_version=RECONCILE_VERSION, reconcile_version=RECONCILE_VERSION,
reconcile_id=final_reconcile_id, reconcile_id=(reconcile_id or f"reconcile-{uuid4().hex[:12]}"),
binding_unchanged=binding_unchanged,
prior_reconcile_id=prior_id,
started_at=_ts(started), started_at=_ts(started),
finished_at=_ts(finished), finished_at=_ts(finished),
boot_head_sha=( boot_head_sha=(
str(inventory.get("boot_head_sha")).strip() str(inventory.get("boot_head_sha")).strip()
if inventory.get("boot_head_sha") if inventory.get("boot_head_sha")
-50
View File
@@ -33,10 +33,6 @@ class _SessionContext:
source: str source: str
pid: int pid: int
canonical_repository_root: str | None = None canonical_repository_root: str | None = None
cohort_id: str | None = None
startup_sha: str | None = None
endpoint: str | None = None
config_fingerprint: str | None = None
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
return { return {
@@ -51,14 +47,9 @@ class _SessionContext:
"source": self.source, "source": self.source,
"pid": self.pid, "pid": self.pid,
"canonical_repository_root": self.canonical_repository_root, "canonical_repository_root": self.canonical_repository_root,
"cohort_id": self.cohort_id,
"startup_sha": self.startup_sha,
"endpoint": self.endpoint,
"config_fingerprint": self.config_fingerprint,
} }
# Process-local only — never a shared file (same rationale as mutation authority). # 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 # The frozen value prevents partial mutation, while the lock makes first-bind and
# sanctioned rebind atomic across concurrent MCP calls. # sanctioned rebind atomic across concurrent MCP calls.
@@ -81,13 +72,6 @@ def _reset_session_context_for_testing() -> None:
_SESSION_CONTEXT = None _SESSION_CONTEXT = None
def clear_session_context() -> None:
"""Purge process-session context and cohort bindings on disconnect."""
global _SESSION_CONTEXT
with _SESSION_CONTEXT_LOCK:
_SESSION_CONTEXT = None
def get_session_context() -> dict[str, Any] | None: def get_session_context() -> dict[str, Any] | None:
"""Return a detached snapshot of the bound context, or None if unbound.""" """Return a detached snapshot of the bound context, or None if unbound."""
with _SESSION_CONTEXT_LOCK: with _SESSION_CONTEXT_LOCK:
@@ -162,10 +146,6 @@ def bind_session_context(
expected_username: str | None = None, expected_username: str | None = None,
source: str = "bind", source: str = "bind",
canonical_repository_root: str | None = None, canonical_repository_root: str | None = None,
cohort_id: str | None = None,
startup_sha: str | None = None,
endpoint: str | None = None,
config_fingerprint: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Atomically bind/re-bind context (the explicit activation path).""" """Atomically bind/re-bind context (the explicit activation path)."""
with _SESSION_CONTEXT_LOCK: with _SESSION_CONTEXT_LOCK:
@@ -180,10 +160,6 @@ def bind_session_context(
expected_username=expected_username, expected_username=expected_username,
source=source, source=source,
canonical_repository_root=canonical_repository_root, canonical_repository_root=canonical_repository_root,
cohort_id=cohort_id,
startup_sha=startup_sha,
endpoint=endpoint,
config_fingerprint=config_fingerprint,
) )
@@ -199,10 +175,6 @@ def _bind_session_context_unlocked(
expected_username: str | None, expected_username: str | None,
source: str, source: str,
canonical_repository_root: str | None = None, canonical_repository_root: str | None = None,
cohort_id: str | None = None,
startup_sha: str | None = None,
endpoint: str | None = None,
config_fingerprint: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Store a complete immutable context while the caller holds the lock.""" """Store a complete immutable context while the caller holds the lock."""
global _SESSION_CONTEXT global _SESSION_CONTEXT
@@ -218,10 +190,6 @@ def _bind_session_context_unlocked(
source=source, source=source,
pid=os.getpid(), pid=os.getpid(),
canonical_repository_root=(canonical_repository_root or "").strip() or None, canonical_repository_root=(canonical_repository_root or "").strip() or None,
cohort_id=(cohort_id or "").strip() or None,
startup_sha=(startup_sha or "").strip() or None,
endpoint=(endpoint or "").strip() or None,
config_fingerprint=(config_fingerprint or "").strip() or None,
) )
return _SESSION_CONTEXT.as_dict() return _SESSION_CONTEXT.as_dict()
@@ -238,10 +206,6 @@ def seed_session_context_if_unbound(
expected_username: str | None = None, expected_username: str | None = None,
source: str = "seed", source: str = "seed",
canonical_repository_root: str | None = None, canonical_repository_root: str | None = None,
cohort_id: str | None = None,
startup_sha: str | None = None,
endpoint: str | None = None,
config_fingerprint: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Atomically bind only when this process has no current context. """Atomically bind only when this process has no current context.
@@ -263,15 +227,10 @@ def seed_session_context_if_unbound(
expected_username=expected_username, expected_username=expected_username,
source=source, source=source,
canonical_repository_root=canonical_repository_root, canonical_repository_root=canonical_repository_root,
cohort_id=cohort_id,
startup_sha=startup_sha,
endpoint=endpoint,
config_fingerprint=config_fingerprint,
) )
return _SESSION_CONTEXT.as_dict() return _SESSION_CONTEXT.as_dict()
def assess_session_context( def assess_session_context(
*, *,
profile_name: str | None, profile_name: str | None,
@@ -627,10 +586,6 @@ def mutation_context_audit_fields(
"session_identity": None, "session_identity": None,
"session_repository": None, "session_repository": None,
"session_org": None, "session_org": None,
"session_cohort_id": None,
"session_startup_sha": None,
"session_endpoint": None,
"session_config_fingerprint": None,
} }
return { return {
"session_context_bound": True, "session_context_bound": True,
@@ -643,14 +598,9 @@ def mutation_context_audit_fields(
"session_role_kind": data.get("role_kind"), "session_role_kind": data.get("role_kind"),
"session_context_source": data.get("source"), "session_context_source": data.get("source"),
"session_canonical_repository_root": data.get("canonical_repository_root"), "session_canonical_repository_root": data.get("canonical_repository_root"),
"session_cohort_id": data.get("cohort_id"),
"session_startup_sha": data.get("startup_sha"),
"session_endpoint": data.get("endpoint"),
"session_config_fingerprint": data.get("config_fingerprint"),
} }
def _assessment( def _assessment(
proven: bool, reasons: list[str], ctx: Mapping[str, Any] | None proven: bool, reasons: list[str], ctx: Mapping[str, Any] | None
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -1,253 +0,0 @@
"""Tests for Issue #689: Deterministic MCP namespace attachment.
Verifies cohort identity exposure, stale cohort refusal, parity matching,
reconcile_id freshness, session context cleanup, and regression scenarios.
"""
from __future__ import annotations
import os
import unittest
from unittest.mock import patch
import master_parity_gate
import mcp_namespace_health
import post_restart_reconcile
import session_context_binding as session_ctx
class TestIssue689DeterministicCohortAttachment(unittest.TestCase):
"""Suite covering Issue #689 acceptance criteria."""
def setUp(self) -> None:
session_ctx._reset_session_context_for_testing()
def tearDown(self) -> None:
session_ctx._reset_session_context_for_testing()
def test_ac1_session_context_exposes_cohort_identity(self) -> None:
"""AC1: Session context exposes cohort ID, startup SHA, endpoint, and config fingerprint."""
ctx = session_ctx.bind_session_context(
profile_name="prgs-author",
remote="prgs",
host="gitea.prgs.cc",
identity="jcwalker3",
repository="Gitea-Tools",
org="Scaled-Tech-Consulting",
role_kind="author",
cohort_id="cohort-p1234-abc123456789",
startup_sha="abc123456789def",
endpoint="gitea.prgs.cc",
config_fingerprint="fingerprint12345",
)
self.assertEqual(ctx["cohort_id"], "cohort-p1234-abc123456789")
self.assertEqual(ctx["startup_sha"], "abc123456789def")
self.assertEqual(ctx["endpoint"], "gitea.prgs.cc")
self.assertEqual(ctx["config_fingerprint"], "fingerprint12345")
fetched = session_ctx.get_session_context()
self.assertIsNotNone(fetched)
self.assertEqual(fetched["cohort_id"], "cohort-p1234-abc123456789")
self.assertEqual(fetched["startup_sha"], "abc123456789def")
def test_ac2_stale_cohort_refused_by_probe_classifier(self) -> None:
"""AC2: Probe classifier refuses binding to a stale cohort as stale_cohort_refused."""
probe_res = {
"success": True,
"cohort": {
"cohort_id": "cohort-obsolete-1",
"startup_sha": "22698c1000000000000000000000000000000000",
"endpoint": "gitea.prgs.cc",
"config_fingerprint": "fp-old",
},
}
res = mcp_namespace_health.classify_namespace_probe(
"gitea-author",
probe_result=probe_res,
probe_source="client_namespace",
expected_parity_sha="a4c73766f4b0cc32f7c3808688eceeb6fee74335",
)
self.assertFalse(res["healthy"])
self.assertFalse(res["success"])
self.assertEqual(res["error_type"], "stale_cohort_refused")
self.assertIn("stale cohort refused", " ".join(res["reasons"]))
self.assertEqual(
res["diagnostics"]["startup_sha"],
"22698c1000000000000000000000000000000000",
)
def test_ac3_reconnection_parity_matching_and_fail_closed(self) -> None:
"""AC3: Parity gate fails closed when bound cohort startup SHA mismatches parity."""
startup = {"startup_head": "a4c73766f4b0cc32f7c3808688eceeb6fee74335"}
current = "a4c73766f4b0cc32f7c3808688eceeb6fee74335"
live_remote = "a4c73766f4b0cc32f7c3808688eceeb6fee74335"
# Matching cohort
matching_cohort = {
"cohort_id": "cohort-fresh",
"startup_sha": "a4c73766f4b0cc32f7c3808688eceeb6fee74335",
}
res_matching = master_parity_gate.assess_master_parity(
startup, current, live_remote_head=live_remote, bound_cohort=matching_cohort
)
self.assertTrue(res_matching["cohort_parity_match"])
self.assertFalse(res_matching["cohort_stale"])
self.assertTrue(res_matching["mutation_safe"])
# Mismatched obsolete cohort
obsolete_cohort = {
"cohort_id": "cohort-obsolete-22698c1",
"startup_sha": "22698c1000000000000000000000000000000000",
}
res_stale = master_parity_gate.assess_master_parity(
startup, current, live_remote_head=live_remote, bound_cohort=obsolete_cohort
)
self.assertFalse(res_stale["cohort_parity_match"])
self.assertTrue(res_stale["cohort_stale"])
self.assertTrue(res_stale["restart_required"])
self.assertFalse(res_stale["mutation_safe"])
def test_ac4_reconcile_id_freshness(self) -> None:
"""AC4: Re-attachment distinguishes new reconcile_id from preserved binding."""
inventory = {"inventory_complete": True}
# New attachment generates fresh reconcile_id
proof1 = post_restart_reconcile.reconcile_after_restart(inventory)
self.assertFalse(proof1.binding_unchanged)
self.assertTrue(proof1.reconcile_id.startswith("reconcile-"))
# Preserved binding reports binding_unchanged=True
proof2 = post_restart_reconcile.reconcile_after_restart(
inventory,
reconcile_id=proof1.reconcile_id,
prior_reconcile_id=proof1.reconcile_id,
)
self.assertTrue(proof2.binding_unchanged)
self.assertEqual(proof2.reconcile_id, proof1.reconcile_id)
# Disconnected re-attachment gets new reconcile_id
proof3 = post_restart_reconcile.reconcile_after_restart(
inventory,
prior_reconcile_id=proof1.reconcile_id,
)
self.assertFalse(proof3.binding_unchanged)
self.assertNotEqual(proof3.reconcile_id, proof1.reconcile_id)
def test_ac5_session_disconnect_clears_bindings(self) -> None:
"""AC5: clear_session_context purges session context on disconnect."""
session_ctx.bind_session_context(
profile_name="prgs-author",
remote="prgs",
host="gitea.prgs.cc",
identity="jcwalker3",
cohort_id="cohort-1",
)
self.assertIsNotNone(session_ctx.get_session_context())
session_ctx.clear_session_context()
self.assertIsNone(session_ctx.get_session_context())
def test_ac6_bound_cohort_in_diagnostics(self) -> None:
"""AC6: Bound cohort identity appears in audit diagnostics."""
session_ctx.bind_session_context(
profile_name="prgs-author",
remote="prgs",
host="gitea.prgs.cc",
identity="jcwalker3",
cohort_id="cohort-test-99",
startup_sha="sha99999",
endpoint="gitea.prgs.cc",
config_fingerprint="fp999",
)
audit = session_ctx.mutation_context_audit_fields()
self.assertTrue(audit["session_context_bound"])
self.assertEqual(audit["session_cohort_id"], "cohort-test-99")
self.assertEqual(audit["session_startup_sha"], "sha99999")
self.assertEqual(audit["session_endpoint"], "gitea.prgs.cc")
self.assertEqual(audit["session_config_fingerprint"], "fp999")
def test_ac7_regression_n_reconnects_never_bind_to_obsolete_daemon(self) -> None:
"""AC7: N reconnects against a daemon set containing obsolete daemons never bind obsolete ones."""
live_master = "master-head-latest-12345"
daemons = [
{"id": "d1", "startup_sha": "obsolete-head-11111"},
{"id": "d2", "startup_sha": "obsolete-head-22698c1"},
{"id": "d3", "startup_sha": live_master},
{"id": "d4", "startup_sha": "obsolete-head-33333"},
]
for _ in range(5):
for daemon in daemons:
res = master_parity_gate.assess_master_parity(
{"startup_head": live_master},
live_master,
live_remote_head=live_master,
bound_cohort=daemon,
)
if daemon["startup_sha"] != live_master:
self.assertFalse(res["mutation_safe"])
self.assertTrue(res["cohort_stale"])
else:
self.assertTrue(res["mutation_safe"])
self.assertFalse(res["cohort_stale"])
def test_ac8_regression_incident_shape_reproduction(self) -> None:
"""AC8: Reproduce incident shape — obsolete cohort 22698c1 resident vs newer daemon."""
live_master = "a4c73766f4b0cc32f7c3808688eceeb6fee74335"
obsolete_cohort = {
"cohort_id": "cohort-resident-22698c1",
"startup_sha": "22698c1000000000000000000000000000000000",
}
new_cohort = {
"cohort_id": "cohort-spawned-new",
"startup_sha": live_master,
}
# Obsolete cohort fails parity check
obs_res = mcp_namespace_health.classify_namespace_probe(
"gitea-author",
probe_result={"success": True, "cohort": obsolete_cohort},
probe_source="client_namespace",
expected_parity_sha=live_master,
)
self.assertFalse(obs_res["healthy"])
self.assertEqual(obs_res["error_type"], "stale_cohort_refused")
# Fresh cohort succeeds
new_res = mcp_namespace_health.classify_namespace_probe(
"gitea-author",
probe_result={"success": True, "cohort": new_cohort},
probe_source="client_namespace",
expected_parity_sha=live_master,
)
self.assertTrue(new_res["healthy"])
def test_ac9_regression_bound_cohort_going_stale_detected(self) -> None:
"""AC9: A bound cohort that later goes stale is detected on next attachment check."""
initial_master = "sha-v1-initial"
cohort = {"cohort_id": "c1", "startup_sha": initial_master}
# Initial state: in parity
res1 = master_parity_gate.assess_master_parity(
{"startup_head": initial_master},
initial_master,
live_remote_head=initial_master,
bound_cohort=cohort,
)
self.assertTrue(res1["mutation_safe"])
# Master advances to sha-v2-advanced while cohort remains at sha-v1-initial
advanced_master = "sha-v2-advanced"
res2 = master_parity_gate.assess_master_parity(
{"startup_head": initial_master},
advanced_master,
live_remote_head=advanced_master,
bound_cohort=cohort,
)
self.assertFalse(res2["mutation_safe"])
self.assertTrue(res2["restart_required"])
self.assertTrue(res2["cohort_stale"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,166 @@
"""Tests for Issue #704: Preventing repository .env files from injecting workspace bindings.
Acceptance Criteria (#704):
1. Repository .env loading cannot populate or override GITEA_ACTIVE_WORKTREE or any role-specific GITEA_*_WORKTREE runtime-binding variable.
2. Runtime workspace bindings are accepted only from sanctioned managed-launch/session mechanisms.
3. Pre-existing sanctioned process environment values retain their intended precedence.
4. Importing gitea_auth or related modules does not mutate workspace-binding state from repository files.
5. Reserved runtime-control keys found in .env are ignored or rejected with a sanitized actionable reason; their values are never logged.
6. The protection applies consistently to author, reviewer, merger, and reconciler namespaces.
7. Comprehensive test coverage for stale worktree, missing worktree, task-specific, role-specific, launcher binding, repeated imports, namespace isolation, precedence, and absence of secret leakage.
8. Dirty-state and workspace-preflight gates cannot be bypassed by an injected missing-path binding.
9. No environment, dotenv, offline-import, or caller-controlled path can forge native transport or mutation provenance.
10. Cross-linked with #702, PR #703, #510.
11. Required immediate follow-up to Issue #702 / PR #703.
"""
from __future__ import annotations
import os
import sys
import tempfile
import importlib
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import gitea_auth
from gitea_auth import is_reserved_worktree_env_key, load_env_file_sanitized
class TestIssue704PreventEnvWorkspaceBindings(unittest.TestCase):
"""Test suite verifying .env workspace-binding injection prevention (#704)."""
def setUp(self):
self.tmpdir = tempfile.TemporaryDirectory()
self.addCleanup(self.tmpdir.cleanup)
self.env_dir = Path(self.tmpdir.name)
def test_is_reserved_worktree_env_key(self):
"""Verify key classification for all role namespaces (#704 AC6)."""
reserved_keys = [
"GITEA_ACTIVE_WORKTREE",
"GITEA_AUTHOR_WORKTREE",
"GITEA_REVIEWER_WORKTREE",
"GITEA_MERGER_WORKTREE",
"GITEA_RECONCILER_WORKTREE",
"gitea_active_worktree",
"GITEA_CUSTOM_ROLE_WORKTREE",
]
for key in reserved_keys:
self.assertTrue(
is_reserved_worktree_env_key(key),
f"Expected {key} to be recognized as a reserved worktree key",
)
unreserved_keys = [
"GITEA_USER",
"GITEA_PASS",
"GITEA_TOKEN",
"GITEA_HOST",
"PATH",
]
for key in unreserved_keys:
self.assertFalse(
is_reserved_worktree_env_key(key),
f"Expected {key} to NOT be recognized as a reserved worktree key",
)
def test_load_env_file_sanitized_ignores_reserved_keys(self):
"""Verify .env loading ignores GITEA_ACTIVE_WORKTREE and role-specific keys (#704 AC1)."""
env_file = self.env_dir / ".env"
stale_path = "/tmp/stale-worktree-path-1234"
env_file.write_text(
f"GITEA_USER=testuser\n"
f"GITEA_ACTIVE_WORKTREE={stale_path}\n"
f"GITEA_AUTHOR_WORKTREE={stale_path}\n"
f"GITEA_REVIEWER_WORKTREE={stale_path}\n"
f"GITEA_MERGER_WORKTREE={stale_path}\n"
f"GITEA_RECONCILER_WORKTREE={stale_path}\n"
)
test_env = {}
reasons = load_env_file_sanitized(str(env_file), target_env=test_env)
# Unreserved key loaded
self.assertEqual(test_env.get("GITEA_USER"), "testuser")
# Reserved keys ignored
self.assertNotIn("GITEA_ACTIVE_WORKTREE", test_env)
self.assertNotIn("GITEA_AUTHOR_WORKTREE", test_env)
self.assertNotIn("GITEA_REVIEWER_WORKTREE", test_env)
self.assertNotIn("GITEA_MERGER_WORKTREE", test_env)
self.assertNotIn("GITEA_RECONCILER_WORKTREE", test_env)
# Rejection reasons populated without leaking the secret value (#704 AC5)
self.assertTrue(len(reasons) >= 5)
for r in reasons:
self.assertNotIn(stale_path, r, "Secret/path value must not leak into rejection reason")
def test_preexisting_sanctioned_launcher_env_retained(self):
"""Sanctioned launcher values in process env are retained (#704 AC2, AC3)."""
sanctioned_path = "/tmp/sanctioned-launcher-worktree"
test_env = {"GITEA_ACTIVE_WORKTREE": sanctioned_path}
env_file = self.env_dir / ".env"
env_file.write_text("GITEA_ACTIVE_WORKTREE=/tmp/injected-repo-worktree\n")
load_env_file_sanitized(str(env_file), target_env=test_env)
# Pre-existing value retained, not overwritten by .env
self.assertEqual(test_env.get("GITEA_ACTIVE_WORKTREE"), sanctioned_path)
def test_stale_or_missing_worktree_in_env_ignored(self):
"""Stale or non-existent worktree path in .env file is ignored (#704 AC7)."""
nonexistent_path = "/nonexistent/branches/stale-issue-999"
env_file = self.env_dir / ".env"
env_file.write_text(f"GITEA_ACTIVE_WORKTREE={nonexistent_path}\n")
test_env = {}
load_env_file_sanitized(str(env_file), target_env=test_env)
self.assertNotIn("GITEA_ACTIVE_WORKTREE", test_env)
def test_repeated_module_import_does_not_mutate_workspace_env(self):
"""Repeated imports of gitea_auth leave os.environ un-contaminated (#704 AC4, AC7)."""
# Ensure no active worktree env exists initially
original_val = os.environ.pop("GITEA_ACTIVE_WORKTREE", None)
try:
importlib.reload(gitea_auth)
self.assertNotIn("GITEA_ACTIVE_WORKTREE", os.environ)
importlib.reload(gitea_auth)
self.assertNotIn("GITEA_ACTIVE_WORKTREE", os.environ)
finally:
if original_val is not None:
os.environ["GITEA_ACTIVE_WORKTREE"] = original_val
def test_namespace_isolation_all_roles_protected(self):
"""Verify protection across author, reviewer, merger, reconciler (#704 AC6)."""
env_file = self.env_dir / ".env"
env_file.write_text(
"GITEA_AUTHOR_WORKTREE=/bad/author\n"
"GITEA_REVIEWER_WORKTREE=/bad/reviewer\n"
"GITEA_MERGER_WORKTREE=/bad/merger\n"
"GITEA_RECONCILER_WORKTREE=/bad/reconciler\n"
)
test_env = {}
load_env_file_sanitized(str(env_file), target_env=test_env)
self.assertEqual(test_env, {})
def test_absence_of_secret_leakage(self):
"""Rejection reasons contain key names but never secret path values (#704 AC5)."""
sensitive_path = "/Users/secret/path/private_repo"
env_file = self.env_dir / ".env"
env_file.write_text(f"GITEA_ACTIVE_WORKTREE={sensitive_path}\n")
test_env = {}
reasons = load_env_file_sanitized(str(env_file), target_env=test_env)
for reason in reasons:
self.assertNotIn(sensitive_path, reason)
if __name__ == "__main__":
unittest.main()