From 5d8cc7bc223cd0754394fe52a0181161b1318410 Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Thu, 6 Aug 2026 19:53:47 +0300 Subject: [PATCH 1/2] feat: add fail-closed repository output gate --- .env.example | 8 + docs/reference/config.md | 24 ++ src/forge/config.py | 28 ++ src/forge/workspace/git_ops.py | 37 ++- src/forge/workspace/output_validation.py | 297 ++++++++++++++++++ .../test_git_ops_output_validation.py | 35 +++ .../unit/workspace/test_output_validation.py | 238 ++++++++++++++ 7 files changed, 666 insertions(+), 1 deletion(-) create mode 100644 src/forge/workspace/output_validation.py create mode 100644 tests/unit/workspace/test_git_ops_output_validation.py create mode 100644 tests/unit/workspace/test_output_validation.py diff --git a/.env.example b/.env.example index 4aecde790..01ef21d0c 100644 --- a/.env.example +++ b/.env.example @@ -144,6 +144,14 @@ SKILLS_DIR=skills/ # setups where containers and the host worker must see the same checkout. # WORKSPACE_BASE_DIR=/var/lib/forge/workspaces +# Trusted output gate (checked immediately before every Git push) +# Optional operator constraints. Unset values add no path or size restrictions; +# repositories can define their own trusted-base .forge-output-policy.yml. +# OUTPUT_PROTECTED_PATHS=.github/workflows/**,.github/CODEOWNERS,CODEOWNERS +# OUTPUT_MAX_FILE_BYTES=10485760 +# OUTPUT_MAX_TOTAL_BYTES=52428800 +# OUTPUT_BASE_REF=origin/main + # ============================================================================= # Prompt Configuration # ============================================================================= diff --git a/docs/reference/config.md b/docs/reference/config.md index f13fd5f29..af37a33fd 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -2,6 +2,30 @@ All configuration is via environment variables in `.env`. See `.env.example` in the repository for the complete list with comments. +## Safe Output Gate + +Forge validates repository changes in the trusted worker immediately before +every Git push. Validation fails closed when Git metadata cannot be inspected, +and enforces configured protected paths, symlink policy, and size limits. +Deletions count as protected-path changes but do not count toward size limits. + +| Variable | Default | Description | +|----------|---------|-------------| +| `OUTPUT_PROTECTED_PATHS` | unset | Optional comma-separated exact paths or glob patterns agents cannot publish | +| `OUTPUT_MAX_FILE_BYTES` | unset | Optional maximum size of an added or modified file | +| `OUTPUT_MAX_TOTAL_BYTES` | unset | Optional maximum combined size of added and modified files | +| `OUTPUT_BASE_REF` | unset | Optional trusted upstream ref; unset resolves `origin/HEAD` | + +When operator constraints are unset, Forge enforces restrictions declared by +`.forge-output-policy.yml` on the trusted repository base. It does not apply +implicit protected-path, size, or symlink defaults. + +The gate evaluates changes from the merge base with `origin/HEAD` through the +current branch tip. A missing remote default-branch reference blocks the push +instead of silently skipping validation. Additional validators, such as secret +scanners, can consume the same `OutputValidationContext` and run in the same +trusted gate. + ## Required Variables ### Jira diff --git a/src/forge/config.py b/src/forge/config.py index d109ce92e..1e916fcf7 100644 --- a/src/forge/config.py +++ b/src/forge/config.py @@ -120,6 +120,34 @@ def atlassian_auth_base64(self) -> str: "Unset (default) uses a per-run system temp directory." ), ) + output_protected_paths: str = Field( + default="", + description="Comma-separated path patterns agents may not publish", + ) + output_max_file_bytes: int | None = Field( + default=None, + ge=1, + description="Optional maximum size of one added or modified file", + ) + output_max_total_bytes: int | None = Field( + default=None, + ge=1, + description="Optional maximum total size of added or modified agent output", + ) + output_base_ref: str = Field( + default="", + description=( + "Trusted upstream ref used as the output-validation base. " + "Unset uses origin/HEAD." + ), + ) + + @property + def protected_output_paths(self) -> tuple[str, ...]: + """Return normalized configured protected path patterns.""" + return tuple( + value.strip() for value in self.output_protected_paths.split(",") if value.strip() + ) # PRD Approval Configuration (global fallbacks — per-project config via # Jira project property forge.prd_proposals_repo takes precedence) diff --git a/src/forge/workspace/git_ops.py b/src/forge/workspace/git_ops.py index 9afe3134a..4500bd321 100644 --- a/src/forge/workspace/git_ops.py +++ b/src/forge/workspace/git_ops.py @@ -2,11 +2,19 @@ import logging import subprocess +from collections.abc import Iterable from pathlib import Path from forge.config import get_settings from forge.utils.redaction import redact_secrets from forge.workspace.manager import Workspace +from forge.workspace.output_validation import ( + OutputValidationError, + OutputValidationPolicy, + OutputValidationResult, + OutputValidator, + validate_repository_output, +) logger = logging.getLogger(__name__) @@ -14,7 +22,13 @@ class GitOperations: """Git operations for cloning, branching, committing, and pushing.""" - def __init__(self, workspace: Workspace): + def __init__( + self, + workspace: Workspace, + *, + output_validators: Iterable[OutputValidator] = (), + output_base_ref: str | None = None, + ): """Initialize git operations for a workspace. Args: @@ -22,6 +36,8 @@ def __init__(self, workspace: Workspace): """ self.workspace = workspace self.settings = get_settings() + self.output_validators = tuple(output_validators) + self.output_base_ref = output_base_ref # Set by workspace recovery when this instance represents a replacement # clone rather than the workspace recorded in workflow state. The path # alone cannot identify that case because managed workspaces reuse a @@ -176,6 +192,7 @@ def push_to_fork(self, force: bool = False) -> None: Args: force: Force push (use with caution). """ + self.validate_output_for_push() args = ["push", "-u", "fork", self.workspace.branch_name] if force: args.insert(1, "--force") @@ -357,6 +374,7 @@ def push(self, force: bool = False, check_conflicts: bool = True) -> None: Raises: GitError: If conflicts detected and check_conflicts is True. """ + self.validate_output_for_push() if check_conflicts and not force: has_conflicts, conflicting_files = self.check_for_conflicts() if has_conflicts: @@ -373,6 +391,23 @@ def push(self, force: bool = False, check_conflicts: bool = True) -> None: self._run_git(*args) logger.info(f"Pushed branch {self.workspace.branch_name}") + def validate_output_for_push(self) -> OutputValidationResult: + """Validate branch output at the trusted boundary before any push.""" + try: + return validate_repository_output( + self.repo_path, + OutputValidationPolicy( + protected_paths=self.settings.protected_output_paths, + max_file_bytes=self.settings.output_max_file_bytes, + max_total_bytes=self.settings.output_max_total_bytes, + ), + self.output_validators, + base_ref=self.output_base_ref or self.settings.output_base_ref or None, + head_ref=f"refs/heads/{self.workspace.branch_name}", + ) + except OutputValidationError as exc: + raise GitError(exc) from exc + def get_current_sha(self) -> str: """Get the current commit SHA. diff --git a/src/forge/workspace/output_validation.py b/src/forge/workspace/output_validation.py new file mode 100644 index 000000000..36dafaf4a --- /dev/null +++ b/src/forge/workspace/output_validation.py @@ -0,0 +1,297 @@ +"""Fail-closed validation of agent-produced repository output.""" + +from __future__ import annotations + +import fnmatch +import logging +import subprocess +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Protocol + +import yaml + +from forge.utils.redaction import redact_secrets + +logger = logging.getLogger(__name__) + + +class OutputValidationError(RuntimeError): + """Raised when repository output is unsafe to publish.""" + + +@dataclass(frozen=True) +class OutputValidationPolicy: + """Policy applied immediately before an external Git write.""" + + protected_paths: tuple[str, ...] = () + max_file_bytes: int | None = None + max_total_bytes: int | None = None + reject_symlinks: bool = False + + +@dataclass(frozen=True) +class ChangedEntry: + """Immutable Git-tree metadata for one changed path.""" + + status: str + mode: str + path: str + size_bytes: int | None + + +@dataclass(frozen=True) +class OutputValidationContext: + """Stable input shared by all output validators.""" + + repo_path: Path + base_ref: str + head_ref: str + changed_entries: tuple[ChangedEntry, ...] + metadata: tuple[tuple[str, object], ...] = () + + @property + def changed_paths(self) -> tuple[str, ...]: + return tuple(entry.path for entry in self.changed_entries) + + +@dataclass(frozen=True) +class OutputValidationResult: + """Successful, structured decision at the repository publication boundary.""" + + base_ref: str + head_ref: str + changed_paths: tuple[str, ...] + validators: tuple[str, ...] + + +class OutputValidator(Protocol): + """Extension point for additional gates such as secret scanners.""" + + name: str + + def validate(self, context: OutputValidationContext) -> None: ... + + +class SafeRepositoryOutputValidator: + """Reject dangerous paths, links, and unexpectedly large output.""" + + name = "safe_repository_output" + + def __init__(self, policy: OutputValidationPolicy): + self.policy = policy + + def validate(self, context: OutputValidationContext) -> None: + violations: list[str] = [] + total_size = 0 + + for entry in context.changed_entries: + status, mode, path = entry.status, entry.mode, entry.path + if not _is_safe_relative_path(path): + violations.append(f"unsafe path: {path!r}") + continue + if _is_protected(path, self.policy.protected_paths): + violations.append(f"protected path changed: {path}") + if status == "D": + continue + if self.policy.reject_symlinks and mode == "120000": + violations.append(f"symbolic link output is not allowed: {path}") + continue + if entry.size_bytes is None: + raise OutputValidationError(f"Missing blob size for {path!r}; push blocked") + size = entry.size_bytes + total_size += size + if self.policy.max_file_bytes is not None and size > self.policy.max_file_bytes: + violations.append( + f"file exceeds {self.policy.max_file_bytes} bytes: {path} ({size} bytes)" + ) + + if self.policy.max_total_bytes is not None and total_size > self.policy.max_total_bytes: + violations.append( + f"changed output exceeds {self.policy.max_total_bytes} bytes ({total_size} bytes)" + ) + if violations: + raise OutputValidationError( + "Unsafe repository output; push blocked: " + "; ".join(violations) + ) + + +def validate_repository_output( + repo_path: Path, + policy: OutputValidationPolicy, + validators: tuple[OutputValidator, ...] = (), + *, + base_ref: str | None = None, + head_ref: str = "HEAD", +) -> OutputValidationResult: + """Run every configured validator, failing closed on inspection errors.""" + + resolved_head = _resolve_ref(repo_path, head_ref, "output branch") + resolved_base = _resolve_base_ref(repo_path, resolved_head, base_ref) + effective_policy = _load_trusted_policy(repo_path, resolved_base, policy) + entries = tuple(_changed_entries(repo_path, resolved_base, resolved_head)) + context = OutputValidationContext( + repo_path=repo_path, + base_ref=resolved_base, + head_ref=resolved_head, + changed_entries=entries, + ) + configured: tuple[OutputValidator, ...] = ( + SafeRepositoryOutputValidator(effective_policy), + *validators, + ) + for validator in configured: + try: + validator.validate(context) + except OutputValidationError: + raise + except Exception as exc: + raise OutputValidationError( + f"Output validator {validator.name!r} failed; push blocked: {redact_secrets(exc)}" + ) from exc + logger.info("Repository output passed %d validation gate(s)", len(configured)) + return OutputValidationResult( + base_ref=context.base_ref, + head_ref=context.head_ref, + changed_paths=context.changed_paths, + validators=tuple(validator.name for validator in configured), + ) + + +def _git(repo_path: Path, *args: str) -> str: + result = subprocess.run( + ["git", *args], cwd=repo_path, capture_output=True, text=True, check=False + ) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or "unknown Git error" + raise OutputValidationError( + f"Unable to inspect repository output; push blocked: {redact_secrets(detail)}" + ) + return result.stdout + + +def _resolve_ref(repo_path: Path, ref: str, label: str) -> str: + try: + return _git(repo_path, "rev-parse", "--verify", f"{ref}^{{commit}}").strip() + except OutputValidationError as exc: + raise OutputValidationError(f"Trusted {label} {ref!r} is unavailable; push blocked") from exc + + +def _resolve_base_ref(repo_path: Path, head_ref: str, configured_ref: str | None) -> str: + if configured_ref: + upstream = configured_ref + else: + result = subprocess.run( + ["git", "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], + cwd=repo_path, + capture_output=True, + text=True, + check=False, + ) + symbolic = result.stdout.strip() + if not symbolic.startswith("refs/remotes/"): + raise OutputValidationError("Remote default branch is unavailable; push blocked") + upstream = symbolic.removeprefix("refs/remotes/") + _resolve_ref(repo_path, upstream, "base ref") + return _git(repo_path, "merge-base", head_ref, upstream).strip() + + +def _load_trusted_policy( + repo_path: Path, base_ref: str, operator_policy: OutputValidationPolicy +) -> OutputValidationPolicy: + """Load repository constraints from the trusted base; constraints only tighten.""" + policy_path = ".forge-output-policy.yml" + result = subprocess.run( + ["git", "show", f"{base_ref}:{policy_path}"], + cwd=repo_path, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return operator_policy + try: + raw = yaml.safe_load(result.stdout) or {} + if not isinstance(raw, dict) or raw.get("version", 1) != 1: + raise ValueError("policy must be a version 1 mapping") + protected = raw.get("protected_paths", []) + if not isinstance(protected, list) or not all(isinstance(item, str) for item in protected): + raise ValueError("protected_paths must be a list of strings") + max_file = _optional_positive_int(raw.get("max_file_bytes"), "max_file_bytes") + max_total = _optional_positive_int(raw.get("max_total_bytes"), "max_total_bytes") + reject_symlinks = raw.get("reject_symlinks", operator_policy.reject_symlinks) + if not isinstance(reject_symlinks, bool): + raise ValueError("reject_symlinks must be boolean") + except (TypeError, ValueError, yaml.YAMLError) as exc: + raise OutputValidationError(f"Invalid trusted {policy_path}; push blocked: {exc}") from exc + return OutputValidationPolicy( + protected_paths=tuple( + dict.fromkeys((*operator_policy.protected_paths, policy_path, *protected)) + ), + max_file_bytes=_stricter_limit(operator_policy.max_file_bytes, max_file), + max_total_bytes=_stricter_limit(operator_policy.max_total_bytes, max_total), + reject_symlinks=operator_policy.reject_symlinks or reject_symlinks, + ) + + +def _optional_positive_int(value: object, name: str) -> int | None: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise ValueError(f"{name} must be a positive integer") + return value + + +def _stricter_limit(operator_limit: int | None, repository_limit: int | None) -> int | None: + limits = [limit for limit in (operator_limit, repository_limit) if limit is not None] + return min(limits) if limits else None + + +def _changed_entries(repo_path: Path, base_ref: str, head_ref: str) -> list[ChangedEntry]: + output = _git( + repo_path, "diff", "--raw", "--no-renames", "--diff-filter=ACDMRTUXB", base_ref, head_ref + ) + entries: list[ChangedEntry] = [] + for line in output.splitlines(): + header, separator, path = line.partition("\t") + if not separator: + raise OutputValidationError("Malformed Git diff metadata; push blocked") + fields = header.split() + if len(fields) != 5: + raise OutputValidationError("Malformed Git diff metadata; push blocked") + old_mode, new_mode, _old_sha, _new_sha, status = fields + kind = status[0] + entries.append( + ChangedEntry( + status=kind, + mode=old_mode if kind == "D" else new_mode, + path=path, + size_bytes=None if kind == "D" else _blob_size(repo_path, head_ref, path), + ) + ) + return entries + + +def _blob_size(repo_path: Path, ref: str, path: str) -> int: + raw = _git(repo_path, "cat-file", "-s", f"{ref}:{path}").strip() + try: + return int(raw) + except ValueError as exc: + raise OutputValidationError(f"Invalid blob size for {path!r}; push blocked") from exc + + +def _is_safe_relative_path(path: str) -> bool: + if not path or "\x00" in path or "\n" in path or "\r" in path: + return False + pure = PurePosixPath(path) + return not pure.is_absolute() and ".." not in pure.parts and not path.startswith("-") + + +def _is_protected(path: str, patterns: tuple[str, ...]) -> bool: + normalized = path.removeprefix("./") + return any( + normalized == pattern.rstrip("/") + or normalized.startswith(pattern.rstrip("/") + "/") + or fnmatch.fnmatchcase(normalized, pattern) + for pattern in patterns + ) diff --git a/tests/unit/workspace/test_git_ops_output_validation.py b/tests/unit/workspace/test_git_ops_output_validation.py new file mode 100644 index 000000000..74467e1b3 --- /dev/null +++ b/tests/unit/workspace/test_git_ops_output_validation.py @@ -0,0 +1,35 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from forge.workspace.git_ops import GitError, GitOperations +from forge.workspace.manager import Workspace +from forge.workspace.output_validation import OutputValidationError + + +def _operations(tmp_path: Path) -> GitOperations: + settings = MagicMock() + settings.protected_output_paths = ("CODEOWNERS",) + settings.output_max_file_bytes = 100 + settings.output_max_total_bytes = 200 + settings.output_base_ref = "" + workspace = Workspace(tmp_path, "org/repo", "forge/task-1", "TASK-1") + with patch("forge.workspace.git_ops.get_settings", return_value=settings): + return GitOperations(workspace) + + +@pytest.mark.parametrize("method", ["push_to_fork", "push"]) +def test_push_methods_validate_before_running_git(tmp_path: Path, method: str) -> None: + git = _operations(tmp_path) + git._run_git = MagicMock() + + with patch( + "forge.workspace.git_ops.validate_repository_output", + side_effect=OutputValidationError("blocked"), + ) as validate, pytest.raises(GitError, match="blocked"): + getattr(git, method)() + + validate.assert_called_once() + assert validate.call_args.kwargs["head_ref"] == "refs/heads/forge/task-1" + git._run_git.assert_not_called() diff --git a/tests/unit/workspace/test_output_validation.py b/tests/unit/workspace/test_output_validation.py new file mode 100644 index 000000000..ede17221c --- /dev/null +++ b/tests/unit/workspace/test_output_validation.py @@ -0,0 +1,238 @@ +"""Tests for the trusted repository-output validation gate.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from forge.workspace.output_validation import ( + OutputValidationError, + OutputValidationPolicy, + validate_repository_output, +) + + +def _run(path: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=path, capture_output=True, text=True, check=True + ).stdout.strip() + + +@pytest.fixture +def repository(tmp_path: Path) -> Path: + origin = tmp_path / "origin.git" + work = tmp_path / "work" + _run(tmp_path, "init", "--bare", "--initial-branch=main", str(origin)) + _run(tmp_path, "clone", str(origin), str(work)) + _run(work, "config", "user.email", "forge@example.com") + _run(work, "config", "user.name", "Forge") + (work / "README.md").write_text("initial\n") + _run(work, "add", "README.md") + _run(work, "commit", "-m", "initial") + _run(work, "push", "-u", "origin", "main") + _run(work, "remote", "set-head", "origin", "main") + _run(work, "switch", "-c", "forge/task-1") + return work + + +def _commit(repo: Path, path: str, content: str) -> None: + target = repo / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content) + _run(repo, "add", path) + _run(repo, "commit", "-m", f"change {path}") + + +def test_accepts_bounded_regular_output(repository: Path) -> None: + _commit(repository, "src/example.py", "print('safe')\n") + + result = validate_repository_output(repository, OutputValidationPolicy()) + + assert result.changed_paths == ("src/example.py",) + assert result.validators == ("safe_repository_output",) + + +@pytest.mark.parametrize("path", [".github/workflows/release.yml", "CODEOWNERS"]) +def test_rejects_protected_path(repository: Path, path: str) -> None: + _commit(repository, path, "unsafe\n") + + with pytest.raises(OutputValidationError, match="protected path changed"): + validate_repository_output( + repository, + OutputValidationPolicy(protected_paths=(".github/workflows/**", "CODEOWNERS")), + ) + + +def test_rejects_symlink_output(repository: Path) -> None: + (repository / "escape").symlink_to("/etc/passwd") + _run(repository, "add", "escape") + _run(repository, "commit", "-m", "add link") + + with pytest.raises(OutputValidationError, match="symbolic link"): + validate_repository_output(repository, OutputValidationPolicy(reject_symlinks=True)) + + +def test_unconfigured_policy_does_not_reject_symlink_or_size(repository: Path) -> None: + (repository / "large-link").symlink_to("README.md") + _run(repository, "add", "large-link") + _run(repository, "commit", "-m", "add link") + + result = validate_repository_output(repository, OutputValidationPolicy()) + + assert result.changed_paths == ("large-link",) + + +def test_trusted_repository_policy_can_reject_symlinks(repository: Path) -> None: + _run(repository, "switch", "main") + _commit( + repository, + ".forge-output-policy.yml", + "version: 1\nreject_symlinks: true\n", + ) + _run(repository, "push", "origin", "main") + _run(repository, "switch", "forge/task-1") + _run(repository, "rebase", "main") + (repository / "output-link").symlink_to("README.md") + _run(repository, "add", "output-link") + _run(repository, "commit", "-m", "add link") + + with pytest.raises(OutputValidationError, match="symbolic link"): + validate_repository_output( + repository, OutputValidationPolicy(), base_ref="origin/main" + ) + + +def test_rejects_oversized_file(repository: Path) -> None: + _commit(repository, "large.txt", "12345") + + with pytest.raises(OutputValidationError, match="file exceeds 4 bytes"): + validate_repository_output(repository, OutputValidationPolicy(max_file_bytes=4)) + + +def test_rejects_oversized_combined_output(repository: Path) -> None: + _commit(repository, "one.txt", "123") + _commit(repository, "two.txt", "456") + + with pytest.raises(OutputValidationError, match="changed output exceeds 5 bytes"): + validate_repository_output(repository, OutputValidationPolicy(max_total_bytes=5)) + + +def test_fails_closed_when_remote_default_branch_is_unknown(repository: Path) -> None: + _commit(repository, "safe.txt", "safe") + _run(repository, "symbolic-ref", "--delete", "refs/remotes/origin/HEAD") + + with pytest.raises(OutputValidationError, match="default branch is unavailable"): + validate_repository_output(repository, OutputValidationPolicy()) + + +def test_configured_base_works_without_remote_head(repository: Path) -> None: + _commit(repository, "safe.txt", "safe") + _run(repository, "symbolic-ref", "--delete", "refs/remotes/origin/HEAD") + + result = validate_repository_output( + repository, OutputValidationPolicy(), base_ref="origin/main" + ) + + assert result.changed_paths == ("safe.txt",) + + +def test_validates_exact_requested_branch_not_head(repository: Path) -> None: + _commit(repository, "safe.txt", "safe") + _run(repository, "branch", "publish-me") + _run(repository, "switch", "main") + + result = validate_repository_output( + repository, + OutputValidationPolicy(), + base_ref="origin/main", + head_ref="refs/heads/publish-me", + ) + + assert result.changed_paths == ("safe.txt",) + assert result.head_ref == _run(repository, "rev-parse", "publish-me") + + +def test_repository_policy_is_loaded_from_trusted_base(repository: Path) -> None: + _run(repository, "switch", "main") + _commit( + repository, + ".forge-output-policy.yml", + "version: 1\nprotected_paths:\n - deploy/**\nmax_file_bytes: 4\n", + ) + _run(repository, "push", "origin", "main") + _run(repository, "switch", "forge/task-1") + _run(repository, "rebase", "main") + _commit(repository, "deploy/app.yml", "unsafe") + + with pytest.raises(OutputValidationError, match="protected path changed"): + validate_repository_output( + repository, OutputValidationPolicy(), base_ref="origin/main" + ) + + +def test_branch_cannot_weaken_trusted_policy(repository: Path) -> None: + _run(repository, "switch", "main") + _commit( + repository, + ".forge-output-policy.yml", + "version: 1\nprotected_paths:\n - deploy/**\n", + ) + _run(repository, "push", "origin", "main") + _run(repository, "switch", "forge/task-1") + _run(repository, "rebase", "main") + _commit(repository, ".forge-output-policy.yml", "version: 1\nprotected_paths: []\n") + _commit(repository, "deploy/app.yml", "unsafe") + + with pytest.raises(OutputValidationError, match="protected path changed"): + validate_repository_output( + repository, OutputValidationPolicy(), base_ref="origin/main" + ) + + +def test_validator_receives_precomputed_immutable_context(repository: Path) -> None: + _commit(repository, "src/example.py", "safe") + + class RecordingValidator: + name = "recording" + + def validate(self, context) -> None: + assert context.changed_entries[0].path == "src/example.py" + with pytest.raises((AttributeError, TypeError)): + context.changed_entries = () + + validate_repository_output( + repository, OutputValidationPolicy(), (RecordingValidator(),) + ) + + +def test_runs_additional_validator_after_safe_path_checks(repository: Path) -> None: + _commit(repository, "src/example.py", "safe") + + class RecordingValidator: + name = "secret_scanner" + + def __init__(self) -> None: + self.paths: tuple[str, ...] = () + + def validate(self, context) -> None: + self.paths = context.changed_paths + + validator = RecordingValidator() + validate_repository_output(repository, OutputValidationPolicy(), (validator,)) + + assert validator.paths == ("src/example.py",) + + +def test_wraps_unexpected_validator_failure_as_fail_closed(repository: Path) -> None: + _commit(repository, "src/example.py", "safe") + + class BrokenValidator: + name = "broken" + + def validate(self, _context) -> None: + raise RuntimeError("scanner unavailable") + + with pytest.raises(OutputValidationError, match="scanner unavailable"): + validate_repository_output(repository, OutputValidationPolicy(), (BrokenValidator(),)) From 76a465e99de4c1400f01c5d0547902708045d200 Mon Sep 17 00:00:00 2001 From: Ella Shulman Date: Sun, 9 Aug 2026 11:55:44 +0300 Subject: [PATCH 2/2] chore: retrigger gates