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 (disabled by default)
|
||||||
WRITE_MODE=false
|
WRITE_MODE=false
|
||||||
WRITE_REPOSITORY_WHITELIST=
|
WRITE_REPOSITORY_WHITELIST=
|
||||||
|
WRITE_ALLOW_ALL_TOKEN_REPOS=false
|
||||||
|
|
||||||
# Automation mode (disabled by default)
|
# Automation mode (disabled by default)
|
||||||
AUTOMATION_ENABLED=false
|
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).
|
- 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.
|
- 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.
|
- 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.
|
- Tamper-evident audit logging with hash-chain integrity validation.
|
||||||
- Secret detection/sanitization for outbound payloads.
|
- Secret detection/sanitization for outbound payloads.
|
||||||
- Structured JSON logging + Prometheus metrics.
|
- 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.
|
- Authorization: policy engine (`policy.yaml`) evaluated before tool execution.
|
||||||
- Rate limiting: per-IP and per-token.
|
- Rate limiting: per-IP and per-token.
|
||||||
- Output controls: bounded response size and optional secret masking/blocking.
|
- 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
|
## Documentation
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Startup validates:
|
|||||||
- Required Gitea settings.
|
- Required Gitea settings.
|
||||||
- API keys (when auth enabled).
|
- API keys (when auth enabled).
|
||||||
- Insecure bind opt-in.
|
- 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
|
## 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.
|
- Per-repository tool allow/deny supported.
|
||||||
- Optional repository path allow/deny supported.
|
- Optional repository path allow/deny supported.
|
||||||
- Write operations are denied by default.
|
- 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
|
## Example Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ Write mode introduces mutation risk (issue/PR changes, metadata updates). Risks
|
|||||||
## Default Posture
|
## Default Posture
|
||||||
|
|
||||||
- `WRITE_MODE=false` by default.
|
- `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.
|
- Policy engine remains authoritative and may deny specific write tools.
|
||||||
|
|
||||||
## Supported Write Tools
|
## Supported Write Tools
|
||||||
@@ -24,7 +25,9 @@ Not supported (explicitly forbidden): merge actions, branch deletion, force push
|
|||||||
## Enablement Steps
|
## Enablement Steps
|
||||||
|
|
||||||
1. Set `WRITE_MODE=true`.
|
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.
|
3. Review policy file for write-tool scope.
|
||||||
4. Verify audit logging and alerting before rollout.
|
4. Verify audit logging and alerting before rollout.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
defaults:
|
defaults:
|
||||||
read: allow
|
read: allow
|
||||||
write: deny
|
write: allow
|
||||||
|
|
||||||
tools:
|
tools:
|
||||||
deny: []
|
deny: []
|
||||||
|
|||||||
@@ -128,6 +128,13 @@ class Settings(BaseSettings):
|
|||||||
description="Comma-separated repository whitelist for write mode (owner/repo)",
|
description="Comma-separated repository whitelist for write mode (owner/repo)",
|
||||||
alias="WRITE_REPOSITORY_WHITELIST",
|
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(
|
automation_enabled: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description="Enable automation endpoints and workflows",
|
description="Enable automation endpoints and workflows",
|
||||||
@@ -221,8 +228,11 @@ class Settings(BaseSettings):
|
|||||||
if len(key) < 32:
|
if len(key) < 32:
|
||||||
raise ValueError("API keys must be at least 32 characters long")
|
raise ValueError("API keys must be at least 32 characters long")
|
||||||
|
|
||||||
if self.write_mode and not write_repositories:
|
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")
|
raise ValueError(
|
||||||
|
"WRITE_MODE=true requires WRITE_REPOSITORY_WHITELIST to be configured "
|
||||||
|
"unless WRITE_ALLOW_ALL_TOKEN_REPOS=true"
|
||||||
|
)
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|||||||
@@ -218,7 +218,10 @@ class PolicyEngine:
|
|||||||
if not repository:
|
if not repository:
|
||||||
return PolicyDecision(False, "write operation requires a repository target")
|
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")
|
return PolicyDecision(False, "repository is not in write-mode whitelist")
|
||||||
|
|
||||||
repo_policy = self.config.repositories.get(repository) if repository else None
|
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()
|
settings2 = get_settings()
|
||||||
|
|
||||||
assert settings1 is settings2
|
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 allowed.allowed
|
||||||
assert denied.allowed is False
|
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