fix(session): bind workspace-verified repository scope at first bind (#714)
The independent review reproduced a real hole: the production first-bind path never pinned repository/org, so the drift checks it feeds were skipped. Root cause ---------- gitea_whoami, gitea_get_runtime_context, gitea_resolve_task_capability and gitea_activate_profile all seeded the session context without repository or org. First-bind-wins then made the mutation gate's later seed a no-op, and assess_session_context only compares repository/org when the bound fields are non-null. Result, reproduced in production order with the real REMOTES: after whoami: repository=None org=None same-host repo=Other-Tools -> NOT BLOCKED same-host org=Other-Org -> NOT BLOCKED cross-host -> blocked (this part always worked) Binding REMOTES defaults would not fix it: REMOTES['prgs'].repo is 'Timesheet' (a default *target*, not an authorization scope, see #530), so pinning it fails closed on every legitimate Gitea-Tools mutation. There was no trusted source for repository scope, so this adds one. Design ------ - New optional profile field `allowed_repositories`: canonical owner/repository slugs. An authorization boundary, never the binding itself. Config-only — no env var can widen or forge it. - The session repository is derived from the verified, workspace-aligned git remote, never from caller-supplied org/repo, and validated against that allowlist. The session binds immutably to exactly one canonical slug; org is derived from it. A profile authorizing several repositories still binds to the single verified one. - Every first-bind entry point now pins the same complete context. Activation rejects an unauthorized/unverifiable workspace. Mutations fail closed when repository/org is unverified, and a tool-level org/repo override that disagrees with the binding is rejected before the write. - A mutation request can no longer establish, complete, or replace the binding (it previously seeded from `org or REMOTES[...]`, i.e. caller-controlled). - Enforcement is opt-in per profile: a profile without the field keeps prior behaviour, so existing static-profile namespaces stay functional. First-bind-wins, immutability, the RLock, profile isolation, host/identity validation and the private pytest-only reset are unchanged. gitea_auth.get_profile() built an explicit whitelist dict and silently dropped `allowed_repositories`; the new integration tests caught that the scope was never enforced through the real path. Tests ----- New tests/test_issue_714_production_first_bind.py drives the real entry points in production order rather than constructing _SessionContext directly. Covers complete first bind via each entry point, same-host repo/org drift, Timesheet and other-repo/other-org rejection, unverified and unauthorized workspaces, caller values unable to establish the binding, concurrent init selecting one repository, and the PR #715 author path staying functional. Mutation-verified: disabling trusted derivation, override validation, the scope check, or the get_profile passthrough each fails these tests (9/8/4/5 failures). No security assertion was weakened, removed, skipped, or rewritten. Focused: 26 passed. Issue #714 total: 46 passed. Prior failing modules: 240 passed. Config/profile/remote modules: 130 passed. Full suite: 2739 passed, 6 skipped, 1 pre-existing warning, 161 subtests in 25.80s. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+93
-9
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user