Close the #714 remediation gap left at 943d402 where env-only mutation
profiles, REMOTES Timesheet defaults treated as explicit caller input, and
machine-dependent Git remotes produced false greens.
- Fail closed when mutations lack a config-backed profile with non-empty
allowed_repositories; env ops cannot invent mutation authority.
- Preserve omitted-vs-explicit org/repo provenance through the mutation
gate; omitted targets bind to the verified workspace repository.
- Prefer workspace-aligned Git remotes over historical Timesheet defaults
in MCP _resolve and CLI resolve_remote.
- Centralize deterministic config-backed mutation fixtures; migrate
mutation tests off env-only authority without weakening security
assertions.
Full suite: 2744 passed, 6 skipped.
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]>