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
+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