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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ evaluations/results/
# Installed locally from an exact source lock. Upstream redistribution permission is unresolved.
skills/alibabacloud-resourcecenter-search/
venv/
.venv/
132 changes: 72 additions & 60 deletions src/titmas_action_gate/pr_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -2024,25 +2024,42 @@ def _evaluate_pull_request(
return receipt, result


def verify_pull_request(
*,
task_path: str | Path,
evidence_path: str | Path,
policy_path: str | Path,
test_command: str,
approval_path: str | Path | None = None,
output_directory: str | Path = DEFAULT_OUTPUT_DIRECTORY,
repository: str | None = None,
pull_request: int | None = None,
head_sha: str | None = None,
execution_identity: str | None = None,
environment: Mapping[str, str] | None = None,
workspace: str | Path | None = None,
action_configuration_path: str | Path | None = None,
action_configuration_root: str | Path | None = None,
) -> dict[str, Any]:
"""Run one bounded PR verification and create its receipt and summary once."""
def _abort_reserved(reserved: ExclusiveOutput | None) -> None:
if reserved is not None:
reserved.__exit__(RuntimeError, RuntimeError("OUTPUT_RESERVATION_ABORTED"), None)


def _reserve_outputs(
directory: Path,
expected_ancestor_identities: tuple[tuple[str, int, int], ...] | None = None,
) -> tuple[ExclusiveOutput, ExclusiveOutput]:
first = ExclusiveOutput(
directory / "receipt.json",
expected_ancestor_identities=expected_ancestor_identities,
)
try:
second = ExclusiveOutput(
directory / "summary.md",
expected_ancestor_identities=expected_ancestor_identities,
)
except Exception:
_abort_reserved(first)
raise
return first, second


def _reserve_private_outputs() -> tuple[Path, ExclusiveOutput, ExclusiveOutput]:
for _attempt in range(8):
private_directory, private_identities = _private_output_directory()
try:
first, second = _reserve_outputs(private_directory, private_identities)
except (FileExistsError, OSError, RuntimeError, UnsafePathError):
continue
return private_directory, first, second
raise UnsafePathError("PRIVATE_OUTPUT_DIRECTORY_UNAVAILABLE")


def _prepare_outputs(output_directory: str | Path) -> tuple[Path, Path, ExclusiveOutput, ExclusiveOutput, bool, str]:
raw_output = Path(output_directory).expanduser()
output_path_syntax_safe = bool(str(raw_output)) and not any(part in {".", ".."} for part in raw_output.parts)
requested_output = Path(os.path.abspath(raw_output)) if output_path_syntax_safe else Path(os.path.abspath("artifacts/titmas"))
Expand All @@ -2054,57 +2071,52 @@ def verify_pull_request(
if not output_preflight_safe:
output_preflight_reason = "OUTPUT_PATH_SYMLINK_NOT_ALLOWED"

def abort_reserved(reserved: ExclusiveOutput | None) -> None:
if reserved is not None:
reserved.__exit__(RuntimeError, RuntimeError("OUTPUT_RESERVATION_ABORTED"), None)

def reserve(
directory: Path,
expected_ancestor_identities: tuple[tuple[str, int, int], ...] | None = None,
) -> tuple[ExclusiveOutput, ExclusiveOutput]:
first = ExclusiveOutput(
directory / "receipt.json",
expected_ancestor_identities=expected_ancestor_identities,
)
try:
second = ExclusiveOutput(
directory / "summary.md",
expected_ancestor_identities=expected_ancestor_identities,
)
except Exception:
abort_reserved(first)
raise
return first, second

def reserve_private() -> tuple[Path, ExclusiveOutput, ExclusiveOutput]:
for _attempt in range(8):
private_directory, private_identities = _private_output_directory()
try:
first, second = reserve(private_directory, private_identities)
except (FileExistsError, OSError, RuntimeError, UnsafePathError):
continue
return private_directory, first, second
raise UnsafePathError("PRIVATE_OUTPUT_DIRECTORY_UNAVAILABLE")

if output_preflight_safe:
try:
receipt_output, summary_output = reserve(output)
receipt_output, summary_output = _reserve_outputs(output)
except UnsafePathError:
output_preflight_safe = False
output_preflight_reason = "OUTPUT_PATH_SYMLINK_NOT_ALLOWED"
except (FileExistsError, OSError, RuntimeError):
output_preflight_safe = False
if not output_preflight_safe:
output, receipt_output, summary_output = reserve_private()
output, receipt_output, summary_output = _reserve_private_outputs()

assert receipt_output is not None and summary_output is not None
return requested_output, output, receipt_output, summary_output, output_preflight_safe, output_preflight_reason


def _output_integrity_state(receipt_output: ExclusiveOutput, summary_output: ExclusiveOutput) -> str:
if not receipt_output._path_is_reserved_inode() or not summary_output._path_is_reserved_inode():
return "IDENTITY_CHANGED"
if not receipt_output.pristine() or not summary_output.pristine():
return "CONTENT_MUTATED"
return "PRISTINE"


def verify_pull_request(
*,
task_path: str | Path,
evidence_path: str | Path,
policy_path: str | Path,
test_command: str,
approval_path: str | Path | None = None,
output_directory: str | Path = DEFAULT_OUTPUT_DIRECTORY,
repository: str | None = None,
pull_request: int | None = None,
head_sha: str | None = None,
execution_identity: str | None = None,
environment: Mapping[str, str] | None = None,
workspace: str | Path | None = None,
action_configuration_path: str | Path | None = None,
action_configuration_root: str | Path | None = None,
) -> dict[str, Any]:
"""Run one bounded PR verification and create its receipt and summary once."""

requested_output, output, receipt_output, summary_output, output_preflight_safe, output_preflight_reason = _prepare_outputs(output_directory)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the recommendation gate before refactoring

This refactor changes the core pull-request gate without updating governance/agent-recommendation-gate.json; at this commit, that record still describes the unrelated CLOSE_FINAL_THREE_MERGE_GATE_P1_FINDINGS task, branch, and base commit. Refresh it for this development scope so the required recommendation, non-recommendation reasons, correction evidence, remaining gaps, and stop conditions are recorded rather than leaving stale governance metadata.

AGENTS.md reference: AGENTS.md:L23-L27

Useful? React with 👍 / 👎.


def output_integrity_state() -> str:
if not receipt_output._path_is_reserved_inode() or not summary_output._path_is_reserved_inode():
return "IDENTITY_CHANGED"
if not receipt_output.pristine() or not summary_output.pristine():
return "CONTENT_MUTATED"
return "PRISTINE"
return _output_integrity_state(receipt_output, summary_output)

observed_output_state = "PRISTINE"

Expand Down Expand Up @@ -2136,9 +2148,9 @@ def output_integrity_check() -> str:
output_relocated = output_state != "PRISTINE"
if output_relocated:
reason = "RESERVED_OUTPUT_IDENTITY_CHANGED" if output_state == "IDENTITY_CHANGED" else "RESERVED_OUTPUT_CONTENT_MUTATED"
abort_reserved(receipt_output)
abort_reserved(summary_output)
output, receipt_output, summary_output = reserve_private()
_abort_reserved(receipt_output)
_abort_reserved(summary_output)
output, receipt_output, summary_output = _reserve_private_outputs()
receipt["output_integrity"] = {
"requested_reference": requested_output.name,
"preflight_safe": output_preflight_safe,
Expand Down
2 changes: 1 addition & 1 deletion src/titmas_action_gate/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import hashlib
import hmac
import json
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
Expand All @@ -20,7 +21,6 @@
from .provider import GitHubProvider
from .signing import HmacRecordSigner
from .store import AppendOnlyStore
from dataclasses import dataclass


@dataclass(frozen=True)
Expand Down
5 changes: 2 additions & 3 deletions tests/test_security_argument_injection.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import unittest
import subprocess
from unittest.mock import patch, MagicMock
from unittest.mock import MagicMock, patch

from titmas_action_gate.provider import GhCliProvider
from titmas_action_gate.errors import ActionGateError


class ProviderSecurityTests(unittest.TestCase):
@patch("subprocess.run")
Expand Down
6 changes: 3 additions & 3 deletions tests/test_workflow.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import json
import tempfile
import unittest
from pathlib import Path

import json
from titmas_action_gate.workflow import validate_agentteams_template, write_demo_report


Expand Down Expand Up @@ -83,7 +83,7 @@ def test_write_demo_report(self):
self.assertEqual(result_path, output_file)
self.assertTrue(output_file.exists())

with open(output_file, "r", encoding="utf-8") as f:
with open(output_file, encoding="utf-8") as f:
content = json.load(f)

self.assertEqual(content, report_data)
Expand All @@ -95,7 +95,7 @@ def test_write_demo_report_creates_directories(self):
write_demo_report(report_data, output_file)

self.assertTrue(output_file.exists())
with open(output_file, "r", encoding="utf-8") as f:
with open(output_file, encoding="utf-8") as f:
content = json.load(f)

self.assertEqual(content, report_data)
Loading