Skip to content
Open
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
# =============================================================================
Expand Down
24 changes: 24 additions & 0 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions src/forge/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
37 changes: 36 additions & 1 deletion src/forge/workspace/git_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,42 @@

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__)


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:
workspace: Workspace to operate on.
"""
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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand All @@ -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.

Expand Down
Loading
Loading