fix(session): pin immutable MCP context; ban cross-host profile substitution (Closes #714) #715

Open
jcwalker3 wants to merge 5 commits from fix/issue-714-session-context-immutability into master
7 changed files with 748 additions and 10 deletions
Showing only changes of commit 943d40270e - Show all commits
+42
View File
@@ -45,6 +45,7 @@ authenticated capability set. Each profile defines the following fields:
| `authenticated_username` | string | The Gitea login this profile authenticates as (verified at runtime via `gitea_whoami`, not trusted from config). |
| `allowed_operations` | list | Operation categories this profile may perform. |
| `forbidden_operations` | list | Operation categories this profile must never perform. |
| `allowed_repositories` | list | Optional. Canonical `owner/repository` slugs this profile may bind to. An authorization boundary, not the binding itself — see [Repository scope](#repository-scope-714). |
| `token_source_name` | string | The *name* of the secret source (e.g. env var name or secret key). **Never the token value.** |
| `audit_label` | string | Short label attached to audit records for actions by this profile. |
| `can_approve_prs` | bool | May submit an approving PR review. |
@@ -57,6 +58,47 @@ authenticated capability set. Each profile defines the following fields:
name), never the token itself. Token values are never part of a profile object,
never logged, never returned by a tool, and never committed.
## Repository scope (#714)
`allowed_repositories` declares the canonical `owner/repository` slugs a profile
may operate on:
```json
"prgs-author": {
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"]
}
```
It is an **authorization boundary, not the session binding**. The binding is
derived and enforced like this:
1. The session repository is derived from the **verified, workspace-aligned git
remote** — never from a caller-supplied `org`/`repo` argument, and never from
the `REMOTES` table (whose entries are default *targets*: `prgs` defaults to
`Timesheet`, which is not this project).
2. That workspace-derived slug is validated against `allowed_repositories`.
3. The session binds immutably to that one canonical `owner/repository`. The
organization is derived from the slug; there is no independent,
caller-controlled organization value.
4. If a profile authorizes several repositories, the verified workspace still
selects exactly one. A session never binds to the whole list and never
switches between entries.
Fail-closed rules:
- Activation is rejected when the workspace repository is absent from the
allowlist.
- A mutation is rejected when no verified workspace repository can be
established.
- A tool-level `org`/`repo` override that disagrees with the binding is rejected
before the mutation runs.
- A mutation request can never establish, complete, or replace the binding.
The field is **config-only**: no environment variable can widen or forge it. It
is optional — a profile that omits it keeps the previous behaviour, so existing
static-profile namespaces stay functional until an operator provisions the
scope. Provisioning it is what activates enforcement for that profile.
## Example profiles
The following are the reference profiles. Booleans express intended capability
+1
View File
@@ -41,6 +41,7 @@
"execution_profile": "example-author",
"audit_label": "example-author",
"auth": { "type": "keychain", "id": "example-gitea-author-token" },
"allowed_repositories": ["Example-Org/Example-Repo"],
"allowed_operations": ["read", "branch", "commit", "push", "open_pr", "comment", "issue.comment"],
"forbidden_operations": ["approve", "request_changes", "merge"]
},
+4
View File
@@ -570,6 +570,10 @@ def get_profile():
"profile_name": name,
"allowed_operations": ops,
"forbidden_operations": forbidden,
# #714 repository authorization boundary. Config-only on purpose: an
# environment variable must never widen or forge the set of
# repositories a session may bind to.
"allowed_repositories": _json_list("allowed_repositories"),
"audit_label": audit_label,
"token_source_name": token_source,
"auth_source_type": auth_type,
+28
View File
@@ -475,6 +475,33 @@ def _require_enabled(kind, name, obj):
return enabled
_REPO_SCOPE_RE = re.compile(r"^[^/\s]+/[^/\s]+$")
def _validate_allowed_repositories(name, raw):
"""Validate the optional per-profile repository authorization scope (#714).
``allowed_repositories`` is an authorization boundary of canonical
``owner/repository`` slugs. It is not the session binding: the verified
workspace repository selects exactly one entry at bind time. Absent means
"no repository scope configured" and is allowed, so existing profiles keep
working until an operator provisions the field.
"""
if raw is None:
return
if not isinstance(raw, list):
raise ConfigError(
f"profile '{name}' allowed_repositories must be a list of "
"'owner/repository' strings"
)
for entry in raw:
if not isinstance(entry, str) or not _REPO_SCOPE_RE.match(entry.strip()):
raise ConfigError(
f"profile '{name}' allowed_repositories entry {entry!r} is not "
"a canonical 'owner/repository' slug"
)
def _reject_inline_secrets(kind, name, obj):
for key in _INLINE_SECRET_KEYS:
if key in obj:
@@ -549,6 +576,7 @@ def _load_v2_contexts(data, path):
forbidden = raw.get("forbidden_operations") or []
if not isinstance(allowed, list) or not isinstance(forbidden, list):
raise ConfigError(f"profile '{name}' operation fields must be lists")
_validate_allowed_repositories(name, raw.get("allowed_repositories"))
allowed_n = {_normalize_op("gitea", op, name) for op in allowed}
forbidden_n = {_normalize_op("gitea", op, name) for op in forbidden}
# Reviewer-identity deadlock rule (#100/#103) applies here unchanged.
+93 -9
View File
@@ -1121,25 +1121,71 @@ import issue_claim_heartbeat # noqa: E402
import session_context_binding as session_ctx # noqa: E402 # #714 immutable session context
def _workspace_repository_slug(remote: str | None) -> str | None:
"""Trusted ``owner/repository`` derived from the workspace git remote (#714).
This is the only source the session binding accepts for repository identity:
it comes from the verified, workspace-aligned checkout rather than from any
caller-supplied tool argument. ``REMOTES`` is deliberately not consulted
its ``org``/``repo`` entries are default *targets*, not an authorization
scope (#530).
"""
if not remote:
return None
parsed = remote_repo_guard.parse_org_repo_from_remote_url(
_local_git_remote_url(remote)
)
if not parsed:
return None
return session_ctx.format_repository_slug(parsed[0], parsed[1])
def _trusted_session_repository(profile: dict, remote: str | None) -> dict:
"""Resolve and authorize the session repository from the verified workspace.
Returns ``org`` / ``repository`` / ``reasons``. A profile's
``allowed_repositories`` is an authorization boundary: the verified
workspace selects exactly one entry from it, and the session never binds to
the list itself nor switches between entries.
"""
slug = _workspace_repository_slug(remote)
scope = session_ctx.assess_repository_scope(
workspace_slug=slug,
allowed=session_ctx.declared_allowed_repositories(profile),
profile_name=profile.get("profile_name"),
)
if scope.get("block"):
return {"org": None, "repository": None, "reasons": list(scope["reasons"])}
parts = session_ctx.parse_repository_slug(slug) if slug else None
if not parts:
return {"org": None, "repository": None, "reasons": []}
return {"org": parts[0], "repository": parts[1], "reasons": []}
def _seed_session_context(
*,
profile: dict,
remote: str | None,
host: str | None,
identity: str | None,
repository: str | None = None,
org: str | None = None,
source: str = "seed",
) -> dict:
"""Seed immutable session context once for the current process (#714)."""
"""Seed immutable session context once for the current process (#714).
Repository and organization are always derived from the verified workspace,
never from caller arguments, so every first-bind entry point (whoami,
runtime context, capability preflight, activation, mutation gate) pins the
same complete context.
"""
expected = (profile.get("username") or "").strip() or None
trusted = _trusted_session_repository(profile, remote)
return session_ctx.seed_session_context_if_unbound(
profile_name=profile.get("profile_name") or "",
remote=remote,
host=host,
identity=identity,
repository=repository,
org=org,
repository=trusted["repository"],
org=trusted["org"],
role_kind=_profile_role_kind(profile),
expected_username=expected,
source=source,
@@ -7621,8 +7667,12 @@ def _session_context_mutation_block(
expected = (profile.get("username") or "").strip() or None
remote_config = REMOTES.get(remote or "", {}) if remote else {}
h = host or remote_config.get("host")
resolved_org = org or remote_config.get("org")
resolved_repo = repo or remote_config.get("repo")
# #714: repository identity comes from the verified workspace only. The
# caller-supplied org/repo are a *request target* to be checked against the
# binding — they must never establish, complete, or replace it.
trusted = _trusted_session_repository(profile, remote)
resolved_org = trusted["org"]
resolved_repo = trusted["repository"]
identity = username
# Legacy env-only profiles do not declare an expected identity. Their
# first mutation still pins remote/host/repository, while existing audit
@@ -7650,14 +7700,15 @@ def _session_context_mutation_block(
)
reasons.extend(id_gate.get("reasons") or [])
# Repository scope denial (unauthorized workspace) fails closed here.
reasons.extend(trusted.get("reasons") or [])
# Seed if unbound so subsequent tools share one context; then assess.
_seed_session_context(
profile=profile,
remote=remote,
host=h,
identity=identity,
repository=resolved_repo,
org=resolved_org,
source="mutation-gate-seed",
)
ctx_gate = session_ctx.assess_session_context(
@@ -7669,8 +7720,21 @@ def _session_context_mutation_block(
org=resolved_org,
expected_username=expected,
require_bound=True,
require_complete=bool(
session_ctx.declared_allowed_repositories(profile)
),
)
reasons.extend(ctx_gate.get("reasons") or [])
# The request may only be checked against the immutable binding (#714).
bound_ctx = session_ctx.get_session_context() or {}
override_gate = session_ctx.assess_repository_override(
requested_org=org,
requested_repo=repo,
bound_org=bound_ctx.get("org"),
bound_repo=bound_ctx.get("repository"),
)
reasons.extend(override_gate.get("reasons") or [])
# De-dupe while preserving order
seen: set[str] = set()
uniq: list[str] = []
@@ -10835,11 +10899,31 @@ def gitea_activate_profile(
os.environ[SESSION_PROFILE_LOCK_ENV] = after_profile
# 4.6 #714: re-bind immutable session context after explicit activation.
# Activation is the sanctioned logical-session boundary, so it establishes
# the complete binding — including the workspace-verified repository. An
# unauthorized workspace fails closed here rather than at first mutation.
activated_trusted = _trusted_session_repository(after_profile_data, remote)
if activated_trusted.get("reasons"):
return {
"success": False,
"performed": False,
"reasons": list(activated_trusted["reasons"]),
"blocker_kind": "repository_scope",
"exact_next_action": (
"BLOCKED + DIAGNOSE: the verified workspace repository is not "
"authorized by this profile's allowed_repositories. Run from a "
"workspace whose git remote matches an authorized "
"owner/repository, or have the operator provision the profile "
"scope. Do not pass org/repo to bypass this gate."
),
}
session_ctx.bind_session_context(
profile_name=after_profile or profile_name,
remote=remote,
host=h,
identity=after_identity,
repository=activated_trusted["repository"],
org=activated_trusted["org"],
role_kind=_profile_role_kind(after_profile_data),
expected_username=expected_username,
source="gitea_activate_profile",
+132 -1
View File
@@ -11,6 +11,7 @@ substitution is forbidden.
from __future__ import annotations
import os
import re
import threading
import urllib.parse
from dataclasses import dataclass
@@ -232,12 +233,16 @@ def assess_session_context(
org: str | None = None,
expected_username: str | None = None,
require_bound: bool = False,
require_complete: 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.
unbound and ``require_bound`` is true, fails closed. ``require_complete``
additionally fails closed when the binding carries no verified
repository/organization identity, so a mutation can never run against an
unknown repository (#714).
"""
reasons: list[str] = []
with _SESSION_CONTEXT_LOCK:
@@ -252,6 +257,15 @@ def assess_session_context(
return _assessment(False, reasons, ctx)
return _assessment(True, reasons, ctx)
if require_complete and not (ctx.get("repository") and ctx.get("org")):
reasons.append(
"session repository/organization identity is unverified "
f"(repository={ctx.get('repository')!r}, org={ctx.get('org')!r}); "
"a mutation cannot proceed without a verified workspace "
"repository (fail closed)"
)
return _assessment(False, 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
@@ -327,6 +341,123 @@ def assess_identity_match(
}
_REPO_SLUG_RE = re.compile(r"^\s*(?P<org>[^/\s]+)\s*/\s*(?P<repo>[^/\s]+?)(?:\.git)?\s*$")
def parse_repository_slug(slug: str | None) -> tuple[str, str] | None:
"""Split a canonical ``owner/repository`` slug, or None when unparseable."""
match = _REPO_SLUG_RE.match(slug or "")
if not match:
return None
return match.group("org"), match.group("repo")
def format_repository_slug(org: str | None, repo: str | None) -> str | None:
"""``owner/repository`` from parts, or None when either side is missing."""
left = (org or "").strip()
right = (repo or "").strip()
if not left or not right:
return None
return f"{left}/{right}"
def declared_allowed_repositories(profile: Mapping[str, Any] | None) -> list[str]:
"""Canonical ``owner/repository`` authorization boundary declared by *profile*.
This list is an authorization boundary, never the session binding itself:
the verified workspace selects exactly one entry (see
:func:`assess_repository_scope`). Returns ``[]`` when the profile declares
no scope — enforcement is opt-in per profile so existing static-profile
namespaces stay functional until an operator provisions the field.
"""
if not profile:
return []
raw = profile.get("allowed_repositories")
if not isinstance(raw, (list, tuple)):
return []
slugs: list[str] = []
for entry in raw:
if not isinstance(entry, str):
continue
parsed = parse_repository_slug(entry)
if parsed:
slugs.append(f"{parsed[0]}/{parsed[1]}")
return slugs
def assess_repository_scope(
*,
workspace_slug: str | None,
allowed: list[str] | None,
profile_name: str | None = None,
) -> dict[str, Any]:
"""Authorize the verified workspace repository against the profile allowlist.
The workspace-derived slug is the only candidate: a profile that authorizes
several repositories still binds to the single verified one, and never to
the list as a whole.
"""
scope = list(allowed or [])
reasons: list[str] = []
if not scope:
return {
"proven": True,
"block": False,
"reasons": reasons,
"scope_enforced": False,
"workspace_slug": workspace_slug,
"allowed_repositories": [],
}
name = profile_name or "(active profile)"
if not workspace_slug:
reasons.append(
f"no verified workspace repository could be established for profile "
f"'{name}'; repository scope cannot be authorized (fail closed)"
)
elif workspace_slug.lower() not in {entry.lower() for entry in scope}:
reasons.append(
f"repository scope denial: workspace repository '{workspace_slug}' "
f"is not authorized by profile '{name}' allowed_repositories "
f"{sorted(scope)} (fail closed)"
)
return {
"proven": not reasons,
"block": bool(reasons),
"reasons": reasons,
"scope_enforced": True,
"workspace_slug": workspace_slug,
"allowed_repositories": sorted(scope),
}
def assess_repository_override(
*,
requested_org: str | None,
requested_repo: str | None,
bound_org: str | None,
bound_repo: str | None,
) -> dict[str, Any]:
"""Caller-supplied org/repo must agree with the immutable binding.
A mutation request is never allowed to establish, complete, or replace the
binding — it may only be checked against it.
"""
reasons: list[str] = []
req_org = (requested_org or "").strip() or None
req_repo = (requested_repo or "").strip() or None
if req_repo and bound_repo and req_repo.lower() != bound_repo.lower():
reasons.append(
f"repository override denial: request targets '{req_repo}' but the "
f"session is bound to '{bound_repo}' (fail closed)"
)
if req_org and bound_org and req_org.lower() != bound_org.lower():
reasons.append(
f"organization override denial: request targets '{req_org}' but the "
f"session is bound to '{bound_org}' (fail closed)"
)
return {"proven": not reasons, "block": bool(reasons), "reasons": reasons}
def filter_profiles_for_remote(
config: dict | None,
remote: str | None,
@@ -0,0 +1,448 @@
"""#714: production first-bind pins the workspace-verified repository scope.
These tests drive the real entry points (``gitea_whoami``,
``gitea_get_runtime_context``, ``gitea_resolve_task_capability``,
``gitea_activate_profile``) in production order. They deliberately do not
construct a ``_SessionContext`` directly: the defect they cover is that the
production first-bind path left ``repository``/``org`` unbound, so
``assess_session_context`` skipped its repository/org drift checks and a
same-host mutation against another repository was not blocked.
The trusted repository identity comes from the workspace-aligned git remote
(``Scaled-Tech-Consulting/Gitea-Tools`` for this checkout), never from
``REMOTES`` (whose ``prgs`` default repo is ``Timesheet``) and never from a
caller-supplied ``org``/``repo`` argument.
"""
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 gitea_mcp_server as srv # noqa: E402
import mcp_server # noqa: E402
import session_context_binding as session_ctx # noqa: E402
WORKSPACE_ORG = "Scaled-Tech-Consulting"
WORKSPACE_REPO = "Gitea-Tools"
WORKSPACE_SLUG = f"{WORKSPACE_ORG}/{WORKSPACE_REPO}"
WORKSPACE_URL = f"https://gitea.prgs.cc/{WORKSPACE_SLUG}.git"
_BASE_AUTHOR_OPS = [
"gitea.read",
"gitea.issue.create",
"gitea.issue.comment",
"gitea.pr.create",
"gitea.pr.comment",
"gitea.branch.push",
"gitea.repo.commit",
]
def _config(allowed_repositories=None):
profile = {
"enabled": True,
"context": "prgs",
"role": "author",
"username": "jcwalker3",
"base_url": "https://gitea.prgs.cc",
"auth": {"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"},
"allowed_operations": list(_BASE_AUTHOR_OPS),
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.request_changes",
],
"execution_profile": "prgs-author",
}
if allowed_repositories is not None:
profile["allowed_repositories"] = list(allowed_repositories)
return {
"version": 2,
# v2-contexts normalizes the config and keeps only rules.* — a
# top-level allow_runtime_switching would be dropped.
"rules": {"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": profile},
}
class _ProductionOrderBase(unittest.TestCase):
"""Real entry points, pinned workspace remote, mocked network only."""
allowed_repositories = [WORKSPACE_SLUG]
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(self.allowed_repositories)))
self._env = {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": "prgs-author",
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
}
gitea_config._active_profile_override = None
mcp_server._IDENTITY_CACHE.clear()
srv._MUTATION_AUTHORITY = None
# Pin the workspace git remote so the trusted source is deterministic
# and never depends on the developer's checkout layout.
self._remote_url = patch.object(
srv, "_local_git_remote_url", side_effect=self._local_remote_url
)
self._remote_url.start()
def tearDown(self):
self._remote_url.stop()
gitea_config._active_profile_override = None
mcp_server._IDENTITY_CACHE.clear()
srv._MUTATION_AUTHORITY = None
self._dir.cleanup()
def _local_remote_url(self, remote_name):
return WORKSPACE_URL if remote_name == "prgs" else None
def _api(self, method, url, header):
return {
"login": "jcwalker3",
"full_name": "Test",
"id": 1,
"email": "[email protected]",
}
def _live(self):
return patch("gitea_mcp_server.api_request", side_effect=self._api)
class TestProductionFirstBindPinsRepository(_ProductionOrderBase):
def test_whoami_first_bind_is_complete(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
ctx = session_ctx.get_session_context()
self.assertIsNotNone(ctx)
self.assertEqual(ctx["org"], WORKSPACE_ORG)
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
self.assertEqual(ctx["remote"], "prgs")
self.assertEqual(ctx["host"], "gitea.prgs.cc")
def test_runtime_context_first_bind_is_complete(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_get_runtime_context(remote="prgs")
ctx = session_ctx.get_session_context()
self.assertEqual(ctx["org"], WORKSPACE_ORG)
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
def test_capability_preflight_first_bind_is_complete(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_resolve_task_capability(task="comment_issue", remote="prgs")
ctx = session_ctx.get_session_context()
self.assertEqual(ctx["org"], WORKSPACE_ORG)
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
def test_activate_profile_binds_complete_context(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
ctx = session_ctx.get_session_context()
self.assertEqual(ctx["org"], WORKSPACE_ORG)
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
class TestProductionOrderRepositoryDriftBlocks(_ProductionOrderBase):
def test_whoami_then_same_host_other_repository_blocks(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
blocked = srv._session_context_mutation_block(
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
)
self.assertIsNotNone(blocked)
self.assertTrue(
any("Other-Tools" in r for r in blocked["reasons"]), blocked["reasons"]
)
def test_whoami_then_timesheet_blocks(self):
"""REMOTES['prgs'].repo is Timesheet — a default target, not a scope."""
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
blocked = srv._session_context_mutation_block(
remote="prgs", org=WORKSPACE_ORG, repo="Timesheet"
)
self.assertIsNotNone(blocked)
self.assertTrue(
any("Timesheet" in r for r in blocked["reasons"]), blocked["reasons"]
)
def test_whoami_then_same_host_other_org_blocks(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
blocked = srv._session_context_mutation_block(
remote="prgs", org="Other-Org", repo=WORKSPACE_REPO
)
self.assertIsNotNone(blocked)
self.assertTrue(
any("Other-Org" in r for r in blocked["reasons"]), blocked["reasons"]
)
def test_runtime_context_then_other_repository_blocks(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_get_runtime_context(remote="prgs")
blocked = srv._session_context_mutation_block(
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
)
self.assertIsNotNone(blocked)
def test_capability_preflight_then_other_repository_blocks(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_resolve_task_capability(task="comment_issue", remote="prgs")
blocked = srv._session_context_mutation_block(
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
)
self.assertIsNotNone(blocked)
def test_activate_profile_then_other_repository_blocks(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
blocked = srv._session_context_mutation_block(
remote="prgs", org="Other-Org", repo="Other-Tools"
)
self.assertIsNotNone(blocked)
def test_cross_host_still_blocks(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
blocked = srv._session_context_mutation_block(remote="dadeschools")
self.assertIsNotNone(blocked)
self.assertTrue(
any("cross-host" in r for r in blocked["reasons"]), blocked["reasons"]
)
def test_authorized_repository_mutation_remains_allowed(self):
"""Normal PR #715 author comment/push path must stay functional."""
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
self.assertIsNone(
srv._session_context_mutation_block(
remote="prgs", org=WORKSPACE_ORG, repo=WORKSPACE_REPO
)
)
# A bare call with no override resolves to the same bound scope.
self.assertIsNone(srv._session_context_mutation_block(remote="prgs"))
class TestMutationRequestCannotEstablishBinding(_ProductionOrderBase):
def test_caller_values_cannot_establish_binding_on_first_mutation(self):
"""A mutation-first session must not be pinned by request values."""
with patch.dict(os.environ, self._env, clear=False), self._live():
self.assertIsNone(session_ctx.get_session_context())
srv._session_context_mutation_block(
remote="prgs", org="Attacker-Org", repo="Attacker-Repo"
)
ctx = session_ctx.get_session_context()
self.assertIsNotNone(ctx)
# Bound to the verified workspace, never to the request values.
self.assertEqual(ctx["org"], WORKSPACE_ORG)
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
def test_mutation_first_with_attacker_values_is_blocked(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
blocked = srv._session_context_mutation_block(
remote="prgs", org="Attacker-Org", repo="Attacker-Repo"
)
self.assertIsNotNone(blocked)
def test_later_call_cannot_overwrite_bound_repository(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
before = session_ctx.get_session_context()
for _ in range(3):
srv._session_context_mutation_block(
remote="prgs", org="Other-Org", repo="Other-Tools"
)
self.assertEqual(session_ctx.get_session_context(), before)
class TestUnverifiedWorkspaceFailsClosed(_ProductionOrderBase):
def _local_remote_url(self, remote_name):
return None # no verifiable workspace repository
def test_mutation_blocks_when_workspace_repository_unverified(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
ctx = session_ctx.get_session_context()
self.assertIsNone(ctx["repository"])
blocked = srv._session_context_mutation_block(
remote="prgs", org=WORKSPACE_ORG, repo=WORKSPACE_REPO
)
self.assertIsNotNone(blocked)
self.assertTrue(
any(
"no verified workspace repository" in r or "unverified" in r
for r in blocked["reasons"]
),
blocked["reasons"],
)
def test_activate_profile_rejects_unverifiable_workspace(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
res = srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
self.assertFalse(res.get("success", True))
self.assertEqual(res.get("blocker_kind"), "repository_scope")
class TestUnauthorizedWorkspaceFailsClosed(_ProductionOrderBase):
allowed_repositories = ["Scaled-Tech-Consulting/Some-Other-Project"]
def test_workspace_absent_from_allowlist_blocks_mutation(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
blocked = srv._session_context_mutation_block(remote="prgs")
self.assertIsNotNone(blocked)
self.assertTrue(
any("not authorized" in r for r in blocked["reasons"]),
blocked["reasons"],
)
def test_workspace_absent_from_allowlist_blocks_activation(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
res = srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
self.assertFalse(res.get("success", True))
self.assertEqual(res.get("blocker_kind"), "repository_scope")
class TestTimesheetNotAuthorizedInThisPhase(_ProductionOrderBase):
"""Cross-project use is paused: only Gitea-Tools is authorized."""
def test_timesheet_workspace_is_rejected(self):
def timesheet_remote(remote_name):
return "https://gitea.prgs.cc/Scaled-Tech-Consulting/Timesheet.git"
with patch.dict(os.environ, self._env, clear=False), self._live():
with patch.object(
srv, "_local_git_remote_url", side_effect=timesheet_remote
):
blocked = srv._session_context_mutation_block(remote="prgs")
self.assertIsNotNone(blocked)
self.assertTrue(
any("not authorized" in r for r in blocked["reasons"]),
blocked["reasons"],
)
class TestConcurrentFirstBindSelectsOneRepository(_ProductionOrderBase):
def test_concurrent_initialization_cannot_change_selected_repository(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
self.assertIsNone(session_ctx.get_session_context())
thread_count = 8
barrier = threading.Barrier(thread_count)
results = []
lock = threading.Lock()
def racer(index: int) -> None:
barrier.wait()
srv._session_context_mutation_block(
remote="prgs", org=f"Racer-Org-{index}", repo=f"Racer-Repo-{index}"
)
with lock:
results.append(session_ctx.get_session_context())
threads = [
threading.Thread(target=racer, args=(i,)) for i in range(thread_count)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=10)
self.assertFalse(any(t.is_alive() for t in threads))
winner = session_ctx.get_session_context()
self.assertEqual(winner["org"], WORKSPACE_ORG)
self.assertEqual(winner["repository"], WORKSPACE_REPO)
self.assertEqual(results, [winner] * thread_count)
class TestRepositoryScopeUnits(unittest.TestCase):
def test_absent_scope_is_not_enforced(self):
scope = session_ctx.assess_repository_scope(
workspace_slug=WORKSPACE_SLUG, allowed=[], profile_name="legacy"
)
self.assertTrue(scope["proven"])
self.assertFalse(scope["scope_enforced"])
def test_declared_scope_authorizes_only_listed_repository(self):
allowed = session_ctx.declared_allowed_repositories(
{"allowed_repositories": [WORKSPACE_SLUG]}
)
self.assertEqual(allowed, [WORKSPACE_SLUG])
self.assertTrue(
session_ctx.assess_repository_scope(
workspace_slug=WORKSPACE_SLUG, allowed=allowed
)["proven"]
)
self.assertTrue(
session_ctx.assess_repository_scope(
workspace_slug="Scaled-Tech-Consulting/Timesheet", allowed=allowed
)["block"]
)
def test_missing_workspace_slug_blocks_when_scope_declared(self):
scope = session_ctx.assess_repository_scope(
workspace_slug=None, allowed=[WORKSPACE_SLUG]
)
self.assertTrue(scope["block"])
def test_override_must_match_binding(self):
self.assertTrue(
session_ctx.assess_repository_override(
requested_org=WORKSPACE_ORG,
requested_repo="Other",
bound_org=WORKSPACE_ORG,
bound_repo=WORKSPACE_REPO,
)["block"]
)
self.assertTrue(
session_ctx.assess_repository_override(
requested_org="Other-Org",
requested_repo=WORKSPACE_REPO,
bound_org=WORKSPACE_ORG,
bound_repo=WORKSPACE_REPO,
)["block"]
)
self.assertTrue(
session_ctx.assess_repository_override(
requested_org=WORKSPACE_ORG,
requested_repo=WORKSPACE_REPO,
bound_org=WORKSPACE_ORG,
bound_repo=WORKSPACE_REPO,
)["proven"]
)
def test_malformed_allowlist_entries_are_ignored(self):
self.assertEqual(
session_ctx.declared_allowed_repositories(
{"allowed_repositories": ["not-a-slug", 17, None, WORKSPACE_SLUG]}
),
[WORKSPACE_SLUG],
)
if __name__ == "__main__":
unittest.main()