1 Commits

Author SHA1 Message Date
sysadmin e0861bcb03 feat: operation-name normalization table with fail-closed enforcement (#106)
Promote the #103 minimal alias map to the documented public table
GITEA_OPERATION_ALIASES and add the #106 enforcement layer:

- normalize_operation(op, service): canonical namespaced names; legacy
  spellings accepted only via the explicit table; unknown, ambiguous,
  and cross-service names fail closed.
- check_operation(op, allowed, forbidden, service): normalizes BOTH the
  requested operation and the profile lists before any membership
  check; forbidden always overrides allowed; unnormalizable allowed
  entries grant nothing and unnormalizable forbidden entries deny the
  request, so normalization can never silently widen permissions;
  empty/missing allowed list denies everything.
- gitea_check_pr_eligibility now routes its capability check through
  check_operation, fixing the mismatch where canonical namespaced
  profile ops (gitea.pr.merge) never matched the raw action (merge)
  and namespaced forbidden entries were never enforced.
- Document the normalization table and enforcement rules in
  docs/gitea-execution-profiles.md, replacing the stale 'enforcement
  out of scope' caveat.
- tests/test_op_normalization.py: full #106 matrix (27 tests) —
  qualified/legacy allowed and forbidden, unknown, ambiguous, service
  mismatch, forbidden-overrides-allowed, empty/missing allowed,
  duplicates after normalization, no permission widening, and
  eligibility integration proving normalization happens before
  enforcement. Existing v1/env unqualified behaviour stays compatible.

Closes #106

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:35:03 -04:00
6 changed files with 371 additions and 214 deletions
+44 -2
View File
@@ -134,8 +134,50 @@ Rules:
appears in both, it is forbidden. appears in both, it is forbidden.
- An operation not present in `allowed_operations` is treated as **not - An operation not present in `allowed_operations` is treated as **not
allowed** (deny by default). allowed** (deny by default).
- These categories are descriptive for this issue. Their runtime enforcement is
out of scope here (see roadmap links). ## Operation-name normalization (#106)
Canonical operation names are namespaced: `{service}.{area}.{verb}` (e.g.
`gitea.pr.merge`, `jenkins.build.read`). Legacy unqualified spellings are
accepted **only** through the explicit alias table below (the code of record
is `GITEA_OPERATION_ALIASES` in `gitea_config.py`; the enforcement matrix is
`tests/test_op_normalization.py`).
| Legacy spelling | Canonical operation |
|-------------------|----------------------------|
| `read` | `gitea.read` |
| `review` | `gitea.pr.review` |
| `comment` | `gitea.pr.comment` |
| `approve` | `gitea.pr.approve` |
| `request_changes` | `gitea.pr.request_changes` |
| `merge` | `gitea.pr.merge` |
| `pr.create` | `gitea.pr.create` |
| `branch.push` | `gitea.branch.push` |
| `branch` | `gitea.branch.create` |
| `commit` | `gitea.repo.commit` |
| `push` | `gitea.branch.push` |
| `open_pr` | `gitea.pr.create` |
For non-Gitea services, a single unqualified word namespaces to the checked
service (`read``jenkins.read` when checking Jenkins); names already
prefixed with that service pass through unchanged.
Enforcement rules (`gitea_config.check_operation`, run **before** any
allowed/forbidden membership check):
- Unknown operation names fail closed (denied).
- Ambiguous names — dotted names that are neither service-prefixed nor in the
alias table — fail closed.
- Cross-service names are never accepted by the wrong service
(`jenkins.read` never matches a Gitea check, and a Gitea alias is never
applied to another service).
- `forbidden_operations` overrides `allowed_operations` after both sides are
normalized, so a legacy spelling can never bypass a canonical forbidden
entry (or vice versa).
- An allowed entry that cannot be normalized grants nothing; a forbidden
entry that cannot be normalized denies the request. Normalization can
therefore never silently widen permissions.
- An empty or missing `allowed_operations` list denies everything.
## Identity and fail-closed rules ## Identity and fail-closed rules
-3
View File
@@ -423,9 +423,6 @@ with the profile and authenticated user when `GITEA_AUDIT_LOG` is set (see
## Releases and version tags ## Releases and version tags
All release tagging, version bumps, and validation must comply with the [Release / Version Process SOP](release-version-sop.md).
Versions follow SemVer — **`vMAJOR.MINOR.PATCH`**, using **`v0.x.y`** while Versions follow SemVer — **`vMAJOR.MINOR.PATCH`**, using **`v0.x.y`** while
unstable. Pick the bump by the largest change since the last tag: unstable. Pick the bump by the largest change since the last tag:
-189
View File
@@ -1,189 +0,0 @@
# Release / Version Process SOP
Operator standard operating procedure for cutting a versioned release of
Gitea-Tools: version bump, checks, merge, tag, and cleanup.
> **Scope.** This is the **human/operator** SOP. It is deliberately distinct
> from [`release-workflows.md`](release-workflows.md), which describes the
> **future `release-mcp` orchestrator** boundary (a coordination concept), not
> the day-to-day tagging process. When they disagree, this document governs how
> a release is actually cut today.
---
## 1. Branch flow
The repo is **`master`-based**. Releases are cut from `master`; there is no
separate `dev`/`release` branch unless and until that is explicitly introduced
and this SOP is updated to match. All work lands on `master` via reviewed PRs
from short-lived, issue-linked branches (e.g. `docs/issue-68-...`).
## 2. Where "the version" lives
There is **no `VERSION` file and no `CHANGELOG` file** in the repo today. The
released version is expressed **only as an annotated git tag** of the form
`vMAJOR.MINOR.PATCH` (existing tags: `v1.0.0`, `v1.0.1`). Release notes are
carried as the **annotated tag's message** (via `--notes-file`), not a tracked
changelog.
> Do **not** confuse this with `SUPPORTED_VERSION` in `gitea_config.py` — that is
> the **config-schema** version, unrelated to the application release version.
If a `VERSION`/`CHANGELOG` file is added later, update this SOP to list it under
"files to update".
## 3. Deciding the version bump (SemVer)
Pick the bump against the last tag using semantic-versioning intent:
* **PATCH** (`v1.0.1 → v1.0.2`): bug fixes, docs, tests, internal cleanups — no
change to tool names, parameters, return payloads, or behavior.
* **MINOR** (`v1.0.1 → v1.1.0`): backward-compatible additions — new MCP tool,
new optional parameter, new script, additive behavior.
* **MAJOR** (`v1.1.0 → v2.0.0`): backward-**incompatible** changes — renamed or
removed tools, changed return-payload shape, changed default behavior, or a
tightened safety gate that rejects previously-accepted input.
When unsure between two levels, choose the higher one.
## 4. Preparing a version-bump / release PR
Releases are still gated by the normal issue-first, PR-reviewed flow.
1. Open (or use) a tracking issue for the release and **claim it** with
`status:in-progress` (see §9).
2. Create an isolated, issue-linked branch + worktree from latest `master`
(e.g. `chore/issue-63-v1.1.0`). Never commit directly to `master`.
3. Include in the PR:
* Any code/docs changes that belong to the release.
* The **release notes** for the annotated tag (draft them in the PR body or a
notes file you will pass to `scripts/release-tag --notes-file`).
* If a `VERSION`/`CHANGELOG` file exists at that time, its update.
4. Open the PR **targeting `master`**.
The tag is **not** created in the PR. Tagging happens only after merge (§6).
## 5. Required checks before release
Run all of these green before merging the release PR and before tagging:
```bash
python3 -m py_compile mcp_server.py
python3 -m py_compile manage_labels.py
bash -n scripts/clear-provenance
./venv/bin/python -m pytest tests/ -q
git diff --check
```
Plus a secret sweep (there is no third-party scanner wired in; do a staged-diff
sweep — see [`developer-testing-guidelines.md`](developer-testing-guidelines.md)
§7):
```bash
git diff --cached | grep -nEi "authorization: (basic|bearer)|password[:=]|token=[A-Za-z0-9]" || echo "clean"
```
`scripts/release-tag` **also** runs the test suite itself before tagging (unless
`--skip-tests` is passed), so tests are enforced twice by default.
## 6. Running `scripts/release-tag`
Tag **only after** the release PR is merged to `master`. `scripts/release-tag`
enforces the tagging policy and is **safe by default** (creates nothing on a
dry-run; never pushes without `--push`).
Before it tags, it requires **all** of:
* version matches `vMAJOR.MINOR.PATCH` (SemVer);
* `fetch --prune` has run;
* you are **on `master`**;
* the worktree is **clean** (no uncommitted changes);
* local `master` **equals** `<remote>/master`;
* `HEAD` is that same commit (the commit is present on remote master);
* the tag does **not** already exist locally or on the remote;
* the test suite passes (unless `--skip-tests`, which warns).
Typical sequence:
```bash
# 1. Dry-run to confirm the plan (changes nothing)
scripts/release-tag --dry-run v1.1.0
# 2. Create the annotated tag locally, with release notes
scripts/release-tag v1.1.0 --notes-file /path/to/release-notes.md
# 3. Push the tag only when ready
scripts/release-tag v1.1.0 --notes-file /path/to/release-notes.md --push
```
Env injection points (mainly for CI/tests):
`RELEASE_TAG_REMOTE` (default `prgs`), `RELEASE_TAG_TEST_CMD`
(default `./venv/bin/python -m pytest tests/ -q`).
## 7. Who may merge / tag
* The release PR must be **merged by someone other than its author** — the
author-cannot-merge safety gate applies to releases exactly as to any other PR.
* Merge uses the gated `gitea_merge_pr` workflow; CLI/legacy merge is disabled.
* Whoever tags must operate on clean master synced to the remote (enforced by
`scripts/release-tag`). Tagging is an operator action performed after merge.
## 8. Self-review / self-merge restrictions
Release PRs are **not** exempt from the safety model:
* No self-review — the author may not approve their own release PR.
* No self-merge — a different eligible identity merges.
* These gates are enforced by the MCP tooling and must not be bypassed.
## 9. Handling `status:in-progress` during release work
* **Claim** the release tracking issue with `status:in-progress` before starting.
* Keep it claimed while the release PR is open and under review.
* On merge/close, the tracker-hygiene automation releases `status:in-progress`
for issues the PR closes; if it remains after the release lands, release it
explicitly. Do not leave a shipped release issue marked in-progress.
## 10. Branch / worktree cleanup after merge
After the release PR merges and the tag is pushed:
* Delete the remote release branch (if repo policy allows).
* Remove the local worktree and delete the local branch:
```bash
git worktree remove branches/<release-worktree>
git branch -d <release-branch>
git worktree prune
```
* Confirm the root repo is clean and on `master` synced to the remote.
## 11. What NOT to do
* **No direct commits to `master`.** All changes land via reviewed PRs.
* **No force-push** (to `master` or to tags).
* **No self-merge** of a release PR.
* **No tagging before merge** — tag only commits already on remote `master`.
* **No release from a dirty worktree** — `scripts/release-tag` refuses, and so
should you.
* **No `--skip-tests`** for a real release unless there is an explicit,
documented reason.
* **No re-tagging / moving an existing tag** — pick the next version instead.
## 12. Post-Merge Verification & Audit Lessons (v1.1.0)
During the v1.1.0 release audit, we identified a critical reconciliation issue (captured in historical PRs/issues #68 and #82):
* **The "Closed" State Trap:** Gitea PRs marked as `closed` are not guaranteed to be `merged` (they can be closed without merging, leading to silent omissions of code/documentation changes).
* **Mandatory Post-Merge File/Commit Presence Probe:** Reviewers/mergers must perform explicit post-merge validation. Do not assume a merge succeeded.
- Check that the merged branch head is an ancestor of the target branch (`master`):
```bash
git fetch <remote> --prune
git merge-base --is-ancestor <pr-head-sha> <remote>/master
```
- Probe file presence for expected modifications/additions:
```bash
git log --oneline -- <expected-file>
# and confirm file presence:
ls -la docs/release-version-sop.md
```
* **Verify in Handoff:** Final report blocks must explicitly document the verification method and probe results.
+72 -14
View File
@@ -70,11 +70,13 @@ _TBD_RE = re.compile(r"(?i)^tbd(-|$)")
# Keys that would mean an inline secret wherever they appear. # Keys that would mean an inline secret wherever they appear.
_INLINE_SECRET_KEYS = ("token", "password", "secret") _INLINE_SECRET_KEYS = ("token", "password", "secret")
# ── Minimal operation normalization (#103) ───────────────────────────────────── # ── Operation-name normalization table (#106; minimal subset landed in #103) ───
# Only what the #103 invariants need. The full normalization table, deprecation # Canonical operations are namespaced ({service}.{area}.{verb}). Legacy
# handling, and enforcement test matrix belong to issue #106 — do not grow this # unqualified spellings are accepted ONLY through this explicit table — never
# beyond invariant safety here. # by guessing. The same table is the documentation of record (see
_MINIMAL_GITEA_OP_MAP = { # docs/gitea-execution-profiles.md) and is exercised by
# tests/test_op_normalization.py.
GITEA_OPERATION_ALIASES = {
"read": "gitea.read", "read": "gitea.read",
"review": "gitea.pr.review", "review": "gitea.pr.review",
"comment": "gitea.pr.comment", "comment": "gitea.pr.comment",
@@ -94,27 +96,83 @@ _REVIEW_MERGE_OPS = frozenset({"gitea.pr.approve", "gitea.pr.merge"})
_AUTHOR_ONLY_OPS = frozenset({"gitea.pr.create", "gitea.branch.push"}) _AUTHOR_ONLY_OPS = frozenset({"gitea.pr.create", "gitea.branch.push"})
def _normalize_op(service, op, addr): def normalize_operation(op, service="gitea"):
"""Normalize *op* for *service*, or fail closed (#103 minimal subset). """Return the canonical namespaced name for *op*, or fail closed (#106).
- already namespaced for this service (``{service}.*``) → unchanged - already namespaced for this service (``{service}.*``) → unchanged
- known unqualified Gitea ops → mapped via ``_MINIMAL_GITEA_OP_MAP`` - known unqualified Gitea ops → mapped via ``GITEA_OPERATION_ALIASES``
- unqualified single-word ops on non-Gitea services → ``{service}.{op}`` - unqualified single-word ops on non-Gitea services → ``{service}.{op}``
- anything else (foreign prefixes, unknown unqualified names) → ConfigError - anything else foreign service prefixes, dotted names outside the
table, unknown unqualified names — is unknown or ambiguous → ConfigError
Normalization never crosses services (a Gitea alias is never applied to
another service) and never widens permissions: an operation that cannot
be normalized grants and matches nothing.
""" """
if not isinstance(op, str) or not op: if not isinstance(op, str) or not op:
raise ConfigError(f"identity '{addr}' has an empty or non-string operation") raise ConfigError("operation must be a non-empty string (fail closed)")
if op.startswith(service + "."): if op.startswith(service + "."):
return op return op
if service == "gitea" and op in _MINIMAL_GITEA_OP_MAP: if service == "gitea" and op in GITEA_OPERATION_ALIASES:
return _MINIMAL_GITEA_OP_MAP[op] return GITEA_OPERATION_ALIASES[op]
if service != "gitea" and "." not in op: if service != "gitea" and "." not in op:
return f"{service}.{op}" return f"{service}.{op}"
raise ConfigError( raise ConfigError(
f"identity '{addr}' has operation {op!r} that cannot be normalized " f"operation {op!r} cannot be normalized safely for service "
f"safely for service '{service}' (fail closed; full table is issue #106)" f"'{service}' (unknown, ambiguous, or cross-service; fail closed)"
) )
def check_operation(op, allowed, forbidden=(), service="gitea"):
"""Decide whether *op* is permitted. Returns ``(bool, reason)`` (#106).
Everything is normalized via :func:`normalize_operation` BEFORE any
membership check, so legacy and canonical spellings always compare equal.
Reasons: ``allowed``, ``invalid-operation``, ``invalid-forbidden-entry``,
``forbidden``, ``no-allowed-operations``, ``not-allowed``.
Fail-closed rules:
- an *op* that cannot be normalized is denied (``invalid-operation``)
- a forbidden entry that cannot be normalized denies the request
(``invalid-forbidden-entry``) — dropping it would silently narrow the
forbidden set, i.e. widen permissions
- an allowed entry that cannot be normalized is ignored — it grants
nothing, so permissions never widen
- ``forbidden`` always overrides ``allowed``
- an empty or missing allowed list denies everything
"""
try:
op_n = normalize_operation(op, service)
except ConfigError:
return (False, "invalid-operation")
forbidden_n = set()
for entry in (forbidden or ()):
try:
forbidden_n.add(normalize_operation(entry, service))
except ConfigError:
return (False, "invalid-forbidden-entry")
if op_n in forbidden_n:
return (False, "forbidden")
if not allowed:
return (False, "no-allowed-operations")
allowed_n = set()
for entry in allowed:
try:
allowed_n.add(normalize_operation(entry, service))
except ConfigError:
continue
if op_n in allowed_n:
return (True, "allowed")
return (False, "not-allowed")
def _normalize_op(service, op, addr):
"""Normalize *op* for identity *addr*, or fail closed with context."""
try:
return normalize_operation(op, service)
except ConfigError as exc:
raise ConfigError(f"identity '{addr}': {exc}") from None
# Default canonical config location (one file shared by all LLM launchers). # Default canonical config location (one file shared by all LLM launchers).
DEFAULT_CONFIG_PATH = os.path.join( DEFAULT_CONFIG_PATH = os.path.join(
os.path.expanduser("~"), ".config", "gitea-tools", "profiles.json" os.path.expanduser("~"), ".config", "gitea-tools", "profiles.json"
+16 -6
View File
@@ -521,14 +521,24 @@ def gitea_check_pr_eligibility(
return result return result
# Profile capability check (metadata only; not enforcement of the action). # Profile capability check (metadata only; not enforcement of the action).
# Both the action and the profile lists are normalized before comparison
# (#106), so legacy spellings ("merge") and canonical namespaced ops
# ("gitea.pr.merge") always match each other and never cross services.
allowed = profile["allowed_operations"] allowed = profile["allowed_operations"]
forbidden = profile["forbidden_operations"] forbidden = profile["forbidden_operations"]
if not allowed: op_ok, op_reason = gitea_config.check_operation(action, allowed, forbidden)
reasons.append("profile has no configured allowed operations (fail closed)") if not op_ok:
if action in forbidden: if op_reason == "no-allowed-operations":
reasons.append(f"profile forbids '{action}'") reasons.append(
elif action not in allowed: "profile has no configured allowed operations (fail closed)")
reasons.append(f"profile is not allowed to {action}") elif op_reason == "forbidden":
reasons.append(f"profile forbids '{action}'")
elif op_reason == "invalid-forbidden-entry":
reasons.append(
"profile has an unrecognized forbidden operation entry "
"(fail closed)")
else:
reasons.append(f"profile is not allowed to {action}")
h, o, r = _resolve(remote, host, org, repo) h, o, r = _resolve(remote, host, org, repo)
+239
View File
@@ -0,0 +1,239 @@
"""Operation-name normalization table and enforcement tests — issue #106.
Covers the required matrix from #106:
- fully qualified allowed / forbidden operations
- legacy unqualified allowed / forbidden operations
- unknown operations (fail closed)
- ambiguous operations (fail closed)
- service mismatch (cross-service names never accepted by the wrong service)
- forbidden-overrides-allowed
- empty / missing allowed list
- duplicate operations after normalization
- no silent permission widening
- eligibility enforcement normalizes before checking
"""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import gitea_config # noqa: E402
from gitea_config import ( # noqa: E402
ConfigError,
check_operation,
normalize_operation,
)
from mcp_server import gitea_check_pr_eligibility # noqa: E402
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
# ---------------------------------------------------------------------------
# normalize_operation — canonical table
# ---------------------------------------------------------------------------
class TestNormalizeOperation(unittest.TestCase):
def test_fully_qualified_gitea_op_unchanged(self):
self.assertEqual(normalize_operation("gitea.pr.merge"), "gitea.pr.merge")
def test_legacy_aliases_map_to_canonical_names(self):
expected = {
"merge": "gitea.pr.merge",
"approve": "gitea.pr.approve",
"request_changes": "gitea.pr.request_changes",
"review": "gitea.pr.review",
"comment": "gitea.pr.comment",
"read": "gitea.read",
}
for legacy, canonical in expected.items():
self.assertEqual(normalize_operation(legacy), canonical)
def test_contexts_shape_author_verbs(self):
self.assertEqual(normalize_operation("branch"), "gitea.branch.create")
self.assertEqual(normalize_operation("commit"), "gitea.repo.commit")
self.assertEqual(normalize_operation("push"), "gitea.branch.push")
self.assertEqual(normalize_operation("open_pr"), "gitea.pr.create")
def test_unknown_unqualified_op_fails_closed(self):
with self.assertRaises(ConfigError):
normalize_operation("frobnicate")
def test_ambiguous_dotted_op_fails_closed(self):
# Dotted but neither gitea-prefixed nor an explicit alias: refuse to
# guess which namespace was meant.
with self.assertRaises(ConfigError):
normalize_operation("build.read")
def test_cross_service_name_rejected_by_wrong_service(self):
with self.assertRaises(ConfigError):
normalize_operation("jenkins.read", service="gitea")
with self.assertRaises(ConfigError):
normalize_operation("gitea.read", service="jenkins")
def test_non_gitea_single_word_namespaced_to_service(self):
self.assertEqual(normalize_operation("read", service="jenkins"),
"jenkins.read")
def test_non_gitea_qualified_own_prefix_unchanged(self):
self.assertEqual(
normalize_operation("jenkins.build.read", service="jenkins"),
"jenkins.build.read",
)
def test_empty_and_non_string_fail_closed(self):
for bad in ("", None, 3, ["merge"]):
with self.assertRaises(ConfigError):
normalize_operation(bad)
def test_gitea_alias_not_applied_to_other_services(self):
# "merge" on jenkins must not resolve to the *gitea* merge permission.
self.assertEqual(normalize_operation("merge", service="jenkins"),
"jenkins.merge")
def test_table_is_documented_and_matches_normalization(self):
table = gitea_config.GITEA_OPERATION_ALIASES
self.assertIsInstance(table, dict)
self.assertTrue(table)
for legacy, canonical in table.items():
self.assertEqual(normalize_operation(legacy), canonical)
self.assertTrue(canonical.startswith("gitea."))
# ---------------------------------------------------------------------------
# check_operation — enforcement semantics (normalize BEFORE checking)
# ---------------------------------------------------------------------------
class TestCheckOperation(unittest.TestCase):
def test_fully_qualified_allowed(self):
ok, reason = check_operation("gitea.pr.merge", ["gitea.pr.merge"])
self.assertTrue(ok)
self.assertEqual(reason, "allowed")
def test_fully_qualified_forbidden(self):
ok, reason = check_operation(
"gitea.pr.merge", ["gitea.pr.merge"], ["gitea.pr.merge"])
self.assertFalse(ok)
self.assertEqual(reason, "forbidden")
def test_legacy_unqualified_allowed(self):
ok, reason = check_operation("merge", ["gitea.pr.merge"])
self.assertTrue(ok)
self.assertEqual(reason, "allowed")
def test_legacy_unqualified_forbidden(self):
ok, reason = check_operation("merge", ["gitea.pr.merge"], ["merge"])
self.assertFalse(ok)
self.assertEqual(reason, "forbidden")
def test_unknown_operation_fails_closed(self):
ok, reason = check_operation("frobnicate", ["gitea.read"])
self.assertFalse(ok)
self.assertEqual(reason, "invalid-operation")
def test_ambiguous_operation_fails_closed(self):
ok, reason = check_operation("build.read", ["gitea.read"])
self.assertFalse(ok)
self.assertEqual(reason, "invalid-operation")
def test_service_mismatch_rejected(self):
ok, reason = check_operation("jenkins.read", ["gitea.read"])
self.assertFalse(ok)
self.assertEqual(reason, "invalid-operation")
def test_forbidden_overrides_allowed_across_spellings(self):
# Allowed via legacy spelling, forbidden via canonical spelling: the
# forbidden entry must win after both normalize to the same op.
ok, reason = check_operation("merge", ["merge"], ["gitea.pr.merge"])
self.assertFalse(ok)
self.assertEqual(reason, "forbidden")
def test_empty_allowed_list_denies(self):
ok, reason = check_operation("gitea.read", [])
self.assertFalse(ok)
self.assertEqual(reason, "no-allowed-operations")
def test_missing_allowed_list_denies(self):
ok, reason = check_operation("gitea.read", None)
self.assertFalse(ok)
self.assertEqual(reason, "no-allowed-operations")
def test_duplicates_after_normalization_are_harmless(self):
ok, reason = check_operation(
"merge", ["merge", "gitea.pr.merge", "merge"])
self.assertTrue(ok)
self.assertEqual(reason, "allowed")
def test_unnormalizable_allowed_entry_grants_nothing(self):
# A junk allowed entry must not widen permissions to anything.
ok, reason = check_operation("gitea.read", ["frobnicate"])
self.assertFalse(ok)
self.assertEqual(reason, "not-allowed")
def test_unnormalizable_forbidden_entry_fails_closed(self):
# If a forbidden entry cannot be understood, deny rather than risk
# silently narrowing the forbidden set (which would widen permissions).
ok, reason = check_operation(
"gitea.read", ["gitea.read"], ["frobnicate"])
self.assertFalse(ok)
self.assertEqual(reason, "invalid-forbidden-entry")
# ---------------------------------------------------------------------------
# Eligibility enforcement — normalization happens before checking (#106)
# ---------------------------------------------------------------------------
class TestEligibilityNormalizesOperations(unittest.TestCase):
def _pr(self, author, state="open", sha="abc123", mergeable=True):
return {
"user": {"login": author},
"state": state,
"head": {"sha": sha},
"mergeable": mergeable,
}
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_namespaced_profile_ops_allow_legacy_action(self, _auth, mock_api):
# JSON-config profiles carry canonical namespaced ops; the raw action
# "merge" must still match them after normalization.
mock_api.side_effect = [{"login": "merger-bot"}, self._pr("author-bot")]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.merge"}
with patch.dict(os.environ, env, clear=True):
r = gitea_check_pr_eligibility(pr_number=9, action="merge",
remote="prgs")
self.assertTrue(r["eligible"])
self.assertNotIn("profile is not allowed to merge", r["reasons"])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_namespaced_forbidden_op_blocks_legacy_action(self, _auth, mock_api):
mock_api.side_effect = [{"login": "merger-bot"}, self._pr("author-bot")]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.merge",
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.merge"}
with patch.dict(os.environ, env, clear=True):
r = gitea_check_pr_eligibility(pr_number=9, action="merge",
remote="prgs")
self.assertFalse(r["eligible"])
self.assertIn("profile forbids 'merge'", r["reasons"])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_legacy_env_ops_still_work(self, _auth, mock_api):
# v1/env behaviour stays compatible: unqualified env ops keep working.
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True):
r = gitea_check_pr_eligibility(pr_number=5, action="review",
remote="prgs")
self.assertTrue(r["eligible"])
if __name__ == "__main__":
unittest.main()