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
+19
View File
@@ -31,6 +31,7 @@ class RouteDecision:
directive_body: str | None = None
directive_author: str | None = None
directive_pragmas: tuple[tuple[str, str], ...] = ()
directive_authorizes_impl: bool = False
@property
def should_queue(self) -> bool:
@@ -125,6 +126,20 @@ def is_maintainer(
return False
def is_implementation_authorizer(
login: str | None,
association: str | None,
*,
maintainers: frozenset[str],
) -> bool:
"""Return whether this author may authorize implementation work."""
if isinstance(login, str) and login and login.lower() in maintainers:
return True
if isinstance(association, str) and association.upper() == "OWNER":
return True
return False
def route(
event_type: str,
payload: Mapping[str, Any],
@@ -184,6 +199,7 @@ def route(
"directive_body": cleaned,
"directive_author": rb_login,
"directive_pragmas": pragmas,
"directive_authorizes_impl": False,
}
if not is_maintainer(login, assoc, maintainers=maintainers):
return {}
@@ -191,11 +207,13 @@ def route(
if stripped is None:
return {}
cleaned, pragmas = parse_pragmas(stripped)
authorizes_impl = is_implementation_authorizer(login, assoc, maintainers=maintainers)
return {
"directive": True,
"directive_body": cleaned,
"directive_author": login,
"directive_pragmas": pragmas,
"directive_authorizes_impl": authorizes_impl,
}
if event_type == "issues":
@@ -352,6 +370,7 @@ __all__ = [
"TRUSTED_ASSOCIATIONS",
"extract_mention",
"is_maintainer",
"is_implementation_authorizer",
"rate_limit_cap",
"route",
"verify_signature",
+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
+1 -3
View File
@@ -53,9 +53,7 @@ def parse_issue_ref(ref: str) -> tuple[str, int]:
cleaned = ref.strip()
match = _ISSUE_REF.match(cleaned) or _ISSUE_URL.match(cleaned)
if match is None:
raise InvalidIssueRef(
f"expected owner/repo#NN or https://github.com/owner/repo/issues/NN, got {ref!r}"
)
raise InvalidIssueRef(f"expected owner/repo#NN or https://github.com/owner/repo/issues/NN, got {ref!r}")
return f"{match.group('owner')}/{match.group('repo')}", int(match.group("number"))
+1 -1
View File
@@ -24,7 +24,7 @@ Read the thread first — reviewer bots (e.g. `chatgpt-codex-connector`) often r
Then branch on request type:
- **Code change** → commit on `{{workspace.branch}}`. NEVER open a second PR; push to this branch. `gh_push_branch` / `gh_open_pr` run `bun run fix` + `bun check` before contacting the remote — you do NOT. After pushing, reply with ONE `gh_post_comment` summarizing the fix, one line per concrete change. Directive bundles multiple issues (e.g. several inline review comments)? Address each and group them in the reply.
- **Code change** → commit on `{{workspace.branch}}`. NEVER open a second PR; push to this branch. `gh_push_branch` / `gh_open_pr` run `bun run fix` + `bun check` before contacting the remote — you do NOT. If these tools refuse on an enhancement/proposal because the directive author lacks implementation authority, reply with ONE `gh_post_comment` explaining that a repo OWNER or allowlisted maintainer must explicitly authorize implementation, then stop. After pushing, reply with ONE `gh_post_comment` summarizing the fix, one line per concrete change. Directive bundles multiple issues (e.g. several inline review comments)? Address each and group them in the reply.
- **Question / clarification** → one `gh_post_comment`. No code change.
- **Explicit stop / drop this** → one ack comment, then halt.
- **Ambiguous** → exactly one clarifying question, then stop. NEVER guess.
@@ -17,7 +17,7 @@ Thread context: {{origin.description}}. PR state: `{{state.pr_status}}`.
Decide what to do:
- **New repro info?** Re-run via `repro_record`, then `gh_post_comment` with the outcome.
- **PR change requested?** Amend `{{workspace.branch}}` and push; NEVER open a second PR. Reply with a short `gh_post_comment` naming what changed.
- **PR change requested?** Amend `{{workspace.branch}}` and push only for an already-open PR / authorized implementation; NEVER open a second PR, and NEVER open the first PR for an unauthorized enhancement/proposal. Reply with a short `gh_post_comment` naming what changed.
- **Confirmation or unrelated question?** Reply with one `gh_post_comment`. Leave code untouched.
- **Bot author or no actionable content?** No-op.
+1
View File
@@ -379,6 +379,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
"body": decision.directive_body,
"author": decision.directive_author,
"pragmas": [list(item) for item in decision.directive_pragmas],
"authorizes_impl": decision.directive_authorizes_impl,
}
if not decision.should_queue:
+6 -1
View File
@@ -54,7 +54,12 @@ def _directive_from_payload(payload: Mapping[str, Any]) -> DirectiveInfo | None:
k, v = entry
if isinstance(k, str) and isinstance(v, str):
pragmas.append((k, v))
return DirectiveInfo(body=body, author=author, pragmas=tuple(pragmas))
return DirectiveInfo(
body=body,
author=author,
pragmas=tuple(pragmas),
authorizes_impl=bool(raw.get("authorizes_impl")),
)
async def _fetch_thread(
+2
View File
@@ -86,6 +86,7 @@ class DirectiveInfo:
author: str
thread: tuple[ThreadMessage, ...] = ()
pragmas: tuple[tuple[str, str], ...] = ()
authorizes_impl: bool = False
def _resolve_pragma_overrides(
@@ -685,6 +686,7 @@ async def run_task(
inbound_thread_number=pr_number,
inbound_is_pr=pr_number is not None,
review_mode=review_mode,
impl_authorized=bool(directive is not None and directive.authorizes_impl),
slot_uid=inputs.slot_uid,
abort=AbortController(),
)