feat: add opt-in write access for all token-visible repos
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
defaults:
|
||||
read: allow
|
||||
write: deny
|
||||
write: allow
|
||||
|
||||
tools:
|
||||
deny: []
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user