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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user