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:
2026-07-15 16:50:41 -04:00
co-authored by Claude Opus 4.8
parent d936da5c87
commit 943d40270e
7 changed files with 748 additions and 10 deletions
+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.