feat(agent): added implementation authorization gate for branch/PR tools

- Added `is_implementation_authorizer` check requiring OWNER or allowlisted maintainer to authorize implementation work.
- Blocked `gh_push_branch` and `gh_open_pr` for unclassified/enhancement/proposal issues without explicit directive authorization.
- Auto-allowed bug and documentation issues without requiring a directive.
- Propagated `authorizes_impl` flag through events, server, tasks, and worker bindings.
This commit is contained in:
can1357
2026-06-02 08:44:59 +02:00
parent 2ecb5fd9fa
commit f18eb90324
13 changed files with 252 additions and 7 deletions
+35
View File
@@ -113,6 +113,9 @@ class ToolBindings:
# True only for incoming-PR review tasks. Review tools require it; mutating
# branch/PR publication tools reject when it is set.
review_mode: bool = False
# Current task is driven by an allowlist/OWNER maintainer directive that
# authorizes implementation. Gates first-PR creation on non-bug/doc issues.
impl_authorized: bool = False
slot_uid: int | None = None
# Set by the worker before launching omp. Carries the abort-task signal
# back out to the worker; `None` for unit tests that exercise tools
@@ -623,6 +626,7 @@ def _build_push_branch(bindings: ToolBindings) -> HostTool[Any, Any]:
msg = "refusing to push: PR review worktrees are read-only."
_audit(bindings, "gh_push_branch", args, error=msg)
_raise_command(msg)
_enforce_impl_authorization(bindings, "gh_push_branch", args, action="push branch")
branch = str(args.get("branch") or bindings.workspace.branch)
skip = bool(args.get("skip_checks", False))
# Same gate as gh_open_pr — formatter + check before bytes leave the
@@ -664,6 +668,7 @@ def _build_open_pr(bindings: ToolBindings) -> HostTool[Any, Any]:
msg = "refusing to open PR: PR review tasks are read-only."
_audit(bindings, "gh_open_pr", args, error=msg)
_raise_command(msg)
_enforce_impl_authorization(bindings, "gh_open_pr", args, action="open PR")
title = args.get("title")
body = args.get("body")
if not isinstance(title, str) or not title.strip():
@@ -982,6 +987,7 @@ def _build_fetch_thread(bindings: ToolBindings) -> HostTool[Any, Any]:
_PRIMARY_TYPES = ("bug", "enhancement", "question", "proposal", "documentation", "invalid", "duplicate")
_AUTO_PR_CLASSIFICATIONS = frozenset({"bug", "documentation"})
_PRIORITIES = ("prio:p0", "prio:p1", "prio:p2", "prio:p3")
_FUNCTIONAL = ("agent", "tool", "tui", "cli", "prompting", "sdk", "auth", "setup", "ux", "providers")
_PLATFORMS = ("platform:linux", "platform:macos", "platform:windows", "platform:wsl")
@@ -990,6 +996,35 @@ _PR_TYPES = ("feat", "fix", "docs", "refactor", "perf", "test", "chore", "ci", "
_CLOSING_ISSUE_RE = re.compile(r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)", re.IGNORECASE)
def _enforce_impl_authorization(
bindings: ToolBindings,
tool_name: str,
args: Mapping[str, Any],
*,
action: str,
) -> None:
"""Refuse first publish on issue classes that require maintainer authorization."""
if bindings.impl_authorized:
return
row = bindings.db.get_issue(bindings.issue_key)
if row is not None:
if row.pr_number is not None:
return
classification = row.classification
if classification in _AUTO_PR_CLASSIFICATIONS:
return
else:
classification = None
classification_phrase = f"classified `{classification}`" if classification else "not classified"
msg = (
f"refusing to {action}: issue #{bindings.issue.number} is {classification_phrase}; "
"a repo OWNER or allowlisted maintainer must @-mention you with an explicit go-ahead "
"before any branch/PR. Post your analysis with `gh_post_comment` and stop."
)
_audit(bindings, tool_name, args, error=msg)
_raise_command(msg)
def _require_review_mode(bindings: ToolBindings, name: str, args: Mapping[str, Any]) -> None:
if bindings.review_mode:
return