Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ 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)
# Agents cannot add, modify, or delete these comma-separated glob/path patterns.
# OUTPUT_PROTECTED_PATHS=.github/workflows/**,.github/CODEOWNERS,.gitlab-ci.yml,CODEOWNERS
# OUTPUT_MAX_FILE_BYTES=10485760
# OUTPUT_MAX_TOTAL_BYTES=52428800

# =============================================================================
# Prompt Configuration
# =============================================================================
Expand Down
20 changes: 20 additions & 0 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

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 rejects protected paths, symbolic links, oversized files, and oversized
combined output. Deletions count as protected-path changes but do not count
toward size limits.

| Variable | Default | Description |
|----------|---------|-------------|
| `OUTPUT_PROTECTED_PATHS` | `.github/workflows/**,.github/CODEOWNERS,.gitlab-ci.yml,CODEOWNERS` | Comma-separated exact paths or glob patterns agents cannot publish |
| `OUTPUT_MAX_FILE_BYTES` | `10485760` | Maximum size of an added or modified file |
| `OUTPUT_MAX_TOTAL_BYTES` | `52428800` | Maximum combined size of added and modified files |

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
Expand Down
21 changes: 21 additions & 0 deletions src/forge/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,27 @@ def atlassian_auth_base64(self) -> str:
"Unset (default) uses a per-run system temp directory."
),
)
output_protected_paths: str = Field(
default=".github/workflows/**,.github/CODEOWNERS,.gitlab-ci.yml,CODEOWNERS",
description="Comma-separated path patterns agents may not publish",
)
output_max_file_bytes: int = Field(
default=10 * 1024 * 1024,
ge=1,
description="Maximum size of one added or modified file published by an agent",
)
output_max_total_bytes: int = Field(
default=50 * 1024 * 1024,
ge=1,
description="Maximum total size of added or modified agent output",
)

@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)
Expand Down
14 changes: 14 additions & 0 deletions src/forge/workspace/git_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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 OutputValidationPolicy, validate_repository_output

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -176,6 +177,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")
Expand Down Expand Up @@ -357,6 +359,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:
Expand All @@ -373,6 +376,17 @@ 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) -> None:
"""Validate branch output at the trusted boundary before any push."""
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,
),
)

def get_current_sha(self) -> str:
"""Get the current commit SHA.

Expand Down
190 changes: 190 additions & 0 deletions src/forge/workspace/output_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
"""Fail-closed validation of agent-produced repository output."""

from __future__ import annotations

import fnmatch
import logging
import subprocess
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
from typing import Protocol

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 = 10 * 1024 * 1024
max_total_bytes: int = 50 * 1024 * 1024
reject_symlinks: bool = True


@dataclass
class OutputValidationContext:
"""Stable input shared by all output validators."""

repo_path: Path
base_ref: str
head_ref: str = "HEAD"
changed_paths: tuple[str, ...] = ()
metadata: dict[str, object] = field(default_factory=dict)


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:
entries = _changed_entries(context.repo_path, context.base_ref, context.head_ref)
context.changed_paths = tuple(path for _, _, path in entries)
violations: list[str] = []
total_size = 0

for status, mode, path in entries:
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
size = _blob_size(context.repo_path, context.head_ref, path)
total_size += size
if size > self.policy.max_file_bytes:
violations.append(
f"file exceeds {self.policy.max_file_bytes} bytes: {path} ({size} bytes)"
)

if 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, ...] = (),
) -> OutputValidationContext:
"""Run every configured validator, failing closed on inspection errors."""

base_ref = _default_base_ref(repo_path)
context = OutputValidationContext(repo_path=repo_path, base_ref=base_ref)
configured: tuple[OutputValidator, ...] = (SafeRepositoryOutputValidator(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 context


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 _default_base_ref(repo_path: Path) -> str:
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")
remote_default = symbolic.removeprefix("refs/remotes/")
return _git(repo_path, "merge-base", "HEAD", remote_default).strip()


def _changed_entries(repo_path: Path, base_ref: str, head_ref: str) -> list[tuple[str, str, str]]:
output = _git(
repo_path,
"diff",
"--raw",
"-z",
"--no-renames",
"--diff-filter=ACDMRTUXB",
base_ref,
head_ref,
)
entries: list[tuple[str, str, str]] = []
fields = output.split("\0")
if fields[-1:] == [""]:
fields.pop()
if len(fields) % 2:
raise OutputValidationError("Malformed Git diff metadata; push blocked")
for header, path in zip(fields[::2], fields[1::2], strict=True):
metadata = header.split()
if len(metadata) != 5:
raise OutputValidationError("Malformed Git diff metadata; push blocked")
old_mode, new_mode, _old_sha, _new_sha, status = metadata
entries.append((status[0], old_mode if status[0] == "D" else new_mode, 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
)
36 changes: 36 additions & 0 deletions tests/unit/workspace/test_git_ops_output_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from forge.workspace.git_ops import 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
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 (
pytest.raises(OutputValidationError, match="blocked"),
patch(
"forge.workspace.git_ops.validate_repository_output",
side_effect=OutputValidationError("blocked"),
) as validate,
):
getattr(git, method)()

validate.assert_called_once()
git._run_git.assert_not_called()
Loading
Loading