From 8504a95a1175b478c0f78a3bb62be02c32b8267f Mon Sep 17 00:00:00 2001 From: latte Date: Sat, 14 Feb 2026 16:35:03 +0100 Subject: [PATCH] feat: add opt-in write access for all token-visible repos --- .env.example | 1 + README.md | 4 ++-- docs/deployment.md | 2 +- docs/policy.md | 4 +++- docs/write-mode.md | 7 +++++-- policy.yaml | 2 +- src/aegis_gitea_mcp/config.py | 14 ++++++++++++-- src/aegis_gitea_mcp/policy.py | 5 ++++- tests/test_config.py | 28 ++++++++++++++++++++++++++++ tests/test_policy.py | 17 +++++++++++++++++ 10 files changed, 74 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 75b8cfe..69c075b 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,7 @@ POLICY_FILE_PATH=policy.yaml # Write mode (disabled by default) WRITE_MODE=false WRITE_REPOSITORY_WHITELIST= +WRITE_ALLOW_ALL_TOKEN_REPOS=false # Automation mode (disabled by default) AUTOMATION_ENABLED=false diff --git a/README.md b/README.md index 8229c60..12914fb 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ AegisGitea-MCP exposes controlled read and optional write capabilities to AI age - Security-first defaults (localhost bind, write mode disabled, no stack traces in production errors). - YAML policy engine with global/per-repository tool allow/deny and optional path restrictions. - Expanded read tools for repositories, commits, diffs, issues, PRs, labels, tags, and releases. -- Strict write mode (opt-in + repository whitelist + policy enforcement). +- Strict write mode (opt-in + policy enforcement, with whitelist by default). - Tamper-evident audit logging with hash-chain integrity validation. - Secret detection/sanitization for outbound payloads. - Structured JSON logging + Prometheus metrics. @@ -57,7 +57,7 @@ Server defaults to `127.0.0.1:8080`. - Authorization: policy engine (`policy.yaml`) evaluated before tool execution. - Rate limiting: per-IP and per-token. - Output controls: bounded response size and optional secret masking/blocking. -- Write controls: `WRITE_MODE=false` by default, repository whitelist required when enabled. +- Write controls: `WRITE_MODE=false` by default; when enabled, use whitelist or opt into `WRITE_ALLOW_ALL_TOKEN_REPOS=true`. ## Documentation diff --git a/docs/deployment.md b/docs/deployment.md index 6ca4584..0fac072 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -36,7 +36,7 @@ Startup validates: - Required Gitea settings. - API keys (when auth enabled). - Insecure bind opt-in. -- Write whitelist when write mode enabled. +- Write whitelist when write mode enabled (unless `WRITE_ALLOW_ALL_TOKEN_REPOS=true`). ## Production Recommendations diff --git a/docs/policy.md b/docs/policy.md index 75a72bc..f4574d3 100644 --- a/docs/policy.md +++ b/docs/policy.md @@ -10,7 +10,9 @@ Aegis uses a YAML policy engine to authorize tool execution before any Gitea API - Per-repository tool allow/deny supported. - Optional repository path allow/deny supported. - Write operations are denied by default. -- Write operations also require `WRITE_MODE=true` and `WRITE_REPOSITORY_WHITELIST` match. +- Write operations also require `WRITE_MODE=true` and either: + - `WRITE_REPOSITORY_WHITELIST` match, or + - `WRITE_ALLOW_ALL_TOKEN_REPOS=true`. ## Example Configuration diff --git a/docs/write-mode.md b/docs/write-mode.md index b2cd411..9e764ed 100644 --- a/docs/write-mode.md +++ b/docs/write-mode.md @@ -7,7 +7,8 @@ Write mode introduces mutation risk (issue/PR changes, metadata updates). Risks ## Default Posture - `WRITE_MODE=false` by default. -- Even when enabled, writes require repository whitelist membership. +- When enabled, writes require repository whitelist membership by default. +- Optional opt-in: `WRITE_ALLOW_ALL_TOKEN_REPOS=true` allows writes to any repo the token can access. - Policy engine remains authoritative and may deny specific write tools. ## Supported Write Tools @@ -24,7 +25,9 @@ Not supported (explicitly forbidden): merge actions, branch deletion, force push ## Enablement Steps 1. Set `WRITE_MODE=true`. -2. Set `WRITE_REPOSITORY_WHITELIST=owner/repo,...`. +2. Choose one: + - `WRITE_REPOSITORY_WHITELIST=owner/repo,...` (recommended) + - `WRITE_ALLOW_ALL_TOKEN_REPOS=true` (broader scope) 3. Review policy file for write-tool scope. 4. Verify audit logging and alerting before rollout. diff --git a/policy.yaml b/policy.yaml index 8fc0613..bd76024 100644 --- a/policy.yaml +++ b/policy.yaml @@ -1,6 +1,6 @@ defaults: read: allow - write: deny + write: allow tools: deny: [] diff --git a/src/aegis_gitea_mcp/config.py b/src/aegis_gitea_mcp/config.py index 9e8b6a9..3a14d14 100644 --- a/src/aegis_gitea_mcp/config.py +++ b/src/aegis_gitea_mcp/config.py @@ -128,6 +128,13 @@ class Settings(BaseSettings): description="Comma-separated repository whitelist for write mode (owner/repo)", alias="WRITE_REPOSITORY_WHITELIST", ) + write_allow_all_token_repos: bool = Field( + default=False, + description=( + "Allow write-mode operations on any repository the token can access. " + "Disabled by default." + ), + ) automation_enabled: bool = Field( default=False, description="Enable automation endpoints and workflows", @@ -221,8 +228,11 @@ class Settings(BaseSettings): if len(key) < 32: raise ValueError("API keys must be at least 32 characters long") - if self.write_mode and not write_repositories: - raise ValueError("WRITE_MODE=true requires WRITE_REPOSITORY_WHITELIST to be configured") + if self.write_mode and not self.write_allow_all_token_repos and not write_repositories: + raise ValueError( + "WRITE_MODE=true requires WRITE_REPOSITORY_WHITELIST to be configured " + "unless WRITE_ALLOW_ALL_TOKEN_REPOS=true" + ) return self diff --git a/src/aegis_gitea_mcp/policy.py b/src/aegis_gitea_mcp/policy.py index b5ae700..22b8fdd 100644 --- a/src/aegis_gitea_mcp/policy.py +++ b/src/aegis_gitea_mcp/policy.py @@ -218,7 +218,10 @@ class PolicyEngine: if not repository: return PolicyDecision(False, "write operation requires a repository target") - if repository not in self.settings.write_repository_whitelist: + if ( + not self.settings.write_allow_all_token_repos + and repository not in self.settings.write_repository_whitelist + ): return PolicyDecision(False, "repository is not in write-mode whitelist") repo_policy = self.config.repositories.get(repository) if repository else None diff --git a/tests/test_config.py b/tests/test_config.py index e0cada7..cc9d322 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -78,3 +78,31 @@ def test_settings_singleton(mock_env: None) -> None: settings2 = get_settings() assert settings1 is settings2 + + +def test_write_mode_requires_whitelist_or_allow_all(monkeypatch: pytest.MonkeyPatch) -> None: + """Write mode without whitelist must be rejected unless allow-all is enabled.""" + monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") + monkeypatch.setenv("GITEA_TOKEN", "test-token") + monkeypatch.setenv("MCP_API_KEYS", "a" * 64) + monkeypatch.setenv("WRITE_MODE", "true") + monkeypatch.delenv("WRITE_REPOSITORY_WHITELIST", raising=False) + monkeypatch.setenv("WRITE_ALLOW_ALL_TOKEN_REPOS", "false") + + reset_settings() + with pytest.raises(ValidationError): + get_settings() + + +def test_write_mode_allows_all_token_repos(monkeypatch: pytest.MonkeyPatch) -> None: + """Allow-all mode should pass validation without explicit repository whitelist.""" + monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") + monkeypatch.setenv("GITEA_TOKEN", "test-token") + monkeypatch.setenv("MCP_API_KEYS", "a" * 64) + monkeypatch.setenv("WRITE_MODE", "true") + monkeypatch.delenv("WRITE_REPOSITORY_WHITELIST", raising=False) + monkeypatch.setenv("WRITE_ALLOW_ALL_TOKEN_REPOS", "true") + + reset_settings() + settings = get_settings() + assert settings.write_allow_all_token_repos is True diff --git a/tests/test_policy.py b/tests/test_policy.py index 2a08fd3..1cae024 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -125,3 +125,20 @@ def test_write_mode_repository_whitelist(monkeypatch: pytest.MonkeyPatch, tmp_pa assert allowed.allowed assert denied.allowed is False + + +def test_write_mode_allow_all_token_repos(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Write mode can be configured to allow all repos accessible to token.""" + _set_base_env(monkeypatch) + monkeypatch.setenv("WRITE_MODE", "true") + monkeypatch.setenv("WRITE_ALLOW_ALL_TOKEN_REPOS", "true") + + policy_path = tmp_path / "policy.yaml" + policy_path.write_text("defaults:\n write: allow\n", encoding="utf-8") + + reset_settings() + _ = get_settings() + engine = PolicyEngine.from_yaml_file(policy_path) + + decision = engine.authorize("create_issue", is_write=True, repository="acme/any-repo") + assert decision.allowed