Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion skills/index.js

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions skills/openhands-automation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -897,3 +897,10 @@ See `references/security.md` — also covers narrowing triggers and sender-level
- **`references/ab-testing.md`** — A/B testing for plugin automations: defining variants with weights, experiment configuration, variant selection logic, observability via conversation tags, and complete examples. Consult this when a user wants to compare plugin versions or configurations.
- **`references/security.md`** — Trust boundaries: untrusted content vs. verified sender, least-privilege secrets, trigger scoping, sender authorization, pre-deploy verification. Consult whenever an automation handles external input or forwards secrets to a spawned conversation.
- **`references/security.md`** — Trust boundaries for automations: untrusted event content vs. verified sender, least-privilege secret scoping for spawned conversations, narrowing triggers, sender-level authorization, and verifying a script actually runs before deploying it. Consult this whenever an automation handles external/untrusted input (GitHub issues/PRs, Slack messages, any public-facing webhook) or forwards secrets to a spawned conversation.

## Repository-scoped GitHub roles

For Docker workflows with separate triage, implementation, review, and merge roles,
use the optional gateway in `scripts/github_factory_gateway.py`. It retains the
GitHub credential in the control plane and exposes role-specific operations. Read
`references/github-factory-gateway.md` for configuration and merge requirements.
51 changes: 51 additions & 0 deletions skills/openhands-automation/references/github-factory-gateway.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# GitHub role gateway

Use `scripts/github_factory_gateway.py` on the trusted control plane when Docker
automations need distinct GitHub grants. The gateway binds to one repository and
keeps the GitHub credential out of worker environments. Each worker receives a
separate random token for its role.

| Role | Granted operations |
| --- | --- |
| Triage | Read backlog, comment, create readiness labels, update issue labels/body |
| Developer | Read snapshots, comment, publish `factory/issue-N` branches, open PRs against `main` |
| Reviewer | Read snapshots, comment on a commit, publish the two factory acceptance statuses |
| Watchdog | Read repository and request a guarded merge |

No role can directly update main, force push, delete or administer the repository,
or invoke an unguarded merge. The developer can initialize an empty repository with
one empty `.gitkeep`, giving its first application PR a base.

## Configuration

Authenticate `gh` on the control plane with a credential restricted to the target
repository: contents, issues, pull requests, and commit statuses write access; checks
read access. Write a mode-0600 JSON file with cryptographically random tokens keyed
`triage`, `developer`, `reviewer`, and `watchdog`. Then run:

```sh
FACTORY_REPOSITORY=owner/repository \
FACTORY_CONTROL_FILE=/private/factory-role-tokens.json \
FACTORY_BIND=172.17.0.1 python3 scripts/github_factory_gateway.py
```

The bind address must be reachable from Docker and restricted to that network.
The default port is 19102. Send POST requests with `Authorization: Bearer ROLE_TOKEN`
and JSON `{method, path, body}`. Paths are repository-relative REST paths, with
additional `/factory/archive`, `/factory/bootstrap`, and `/factory/merge` operations.
The gateway validates role grants before sending requests to GitHub. Never put the
control-plane credential or another role's token in worker configuration.

`/factory/archive` takes an exact commit SHA and returns a base64 tarball capped at
25 MB, supporting private repositories without credential-bearing git remotes.
Workers must extract with a safe archive filter. `/factory/merge` takes a PR number
and reviewed SHA. It checks the current PR head, base ancestry, mergeability,
`software-factory/tests` and `software-factory/review`, and other CI results. Missing,
stale, failed, pending, or incompletely paginated checks reject the merge. The final
squash-merge request includes the reviewed SHA to prevent a head-update race.

Review uses COMMENT plus explicit acceptance statuses because GitHub does not allow
self-approval when the roles share an installation identity. Role separation must
also exist in execution: use fresh Docker runtimes and independent reviewer agents.
Code executing within a role's sandbox can access that role's token; use a separate
untrusted CI worker if stronger credential separation is required.
248 changes: 248 additions & 0 deletions skills/openhands-automation/scripts/github_factory_gateway.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
"""Repository-scoped GitHub gateway for the four factory roles.

Run in the trusted control plane. Workers receive role tokens, never the GitHub
credential. No arbitrary URLs, GraphQL, repository administration, or main-branch
writes are exposed. The merge operation independently checks current evidence.
"""

import base64
import hashlib
import json
import os
import re
import secrets
import subprocess
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.error import HTTPError
from urllib.request import Request, urlopen


CONTROL = {}
TOKEN = ""
ROOT = ""


def configure():
global CONTROL, TOKEN, ROOT
repo = os.environ["FACTORY_REPOSITORY"]
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repo):
raise ValueError("Expected owner/repository")
CONTROL = json.loads(Path(os.environ["FACTORY_CONTROL_FILE"]).read_text())
TOKEN = subprocess.check_output(["gh", "auth", "token"], text=True).strip()
ROOT = f"https://api.github.com/repos/{repo}"


def github(method, path, body=None):
request = Request(
ROOT + path,
data=json.dumps(body).encode() if body is not None else None,
method=method,
headers={
"Authorization": "Bearer " + TOKEN,
"Accept": "application/vnd.github+json",
"Content-Type": "application/json",
},
)
with urlopen(request, timeout=60) as response:
data = response.read()
return json.loads(data) if data else {}


def archive(sha):
if not re.fullmatch(r"[0-9a-f]{40}", sha):
raise ValueError("Archive requires an exact commit SHA")
req = Request(
ROOT + "/tarball/" + sha, headers={"Authorization": "Bearer " + TOKEN}
)
with urlopen(req, timeout=90) as response:
content = response.read(25_000_001)
if len(content) > 25_000_000:
raise ValueError("Repository archive exceeds 25 MB")
return {"sha": sha, "tarball": base64.b64encode(content).decode()}


def latest_statuses(sha):
result = {}
for status in github("GET", f"/commits/{sha}/statuses?per_page=100"):
result.setdefault(status["context"], status)
Comment thread
neubig marked this conversation as resolved.
Outdated
return result
Comment thread
neubig marked this conversation as resolved.
Outdated


def permitted(role, method, path, body):
route = path.split("?", 1)[0]
if method == "GET":
if role in ("triage", "developer", "reviewer") and re.fullmatch(
r"/issues(?:/\d+(?:/comments)?)?", route
):
return True
if role == "triage" and route == "/labels":
return True
if role in ("developer", "reviewer", "watchdog") and (
re.fullmatch(r"/pulls(?:/\d+(?:/reviews|/comments)?)?", route)
or re.fullmatch(r"/commits/[0-9a-f]{40}/(?:statuses|check-runs)", route)
):
return True
if role in ("developer", "reviewer") and re.fullmatch(
r"/git/ref/heads/(?:main|factory/issue-\d+)", route
):
return True
return role == "developer" and bool(
re.fullmatch(r"/git/commits/[0-9a-f]{40}", route)
)
if re.fullmatch(r"/issues/\d+/comments", route) and method == "POST":
return role in ("triage", "developer", "reviewer") and set(body) == {"body"}
if role == "triage":
if method == "PATCH" and re.fullmatch(r"/issues/\d+", route):
return set(body) <= {"body", "labels"}
if method == "POST" and route == "/labels":
return body.get("name") in {
"ready-for-dev",
"priority:high",
"factory:attention",
}
if role == "developer":
if method == "POST" and route in {"/git/blobs", "/git/trees", "/git/commits"}:
return True
if method == "POST" and route == "/git/refs":
return bool(
re.fullmatch(r"refs/heads/factory/issue-\d+", body.get("ref", ""))
)
if method == "PATCH" and re.fullmatch(
r"/git/refs/heads/factory/issue-\d+", route
):
return body.get("force") is not True and set(body) <= {"sha", "force"}
Comment thread
neubig marked this conversation as resolved.
Outdated
if method == "POST" and route == "/pulls":
return body.get("base") == "main" and bool(
re.fullmatch(r"factory/issue-\d+", body.get("head", ""))
)
if role == "reviewer":
if method == "POST" and re.fullmatch(r"/pulls/\d+/reviews", route):
return body.get("event") == "COMMENT" and bool(body.get("commit_id"))
if method == "POST" and re.fullmatch(r"/statuses/[0-9a-f]{40}", route):
return body.get("context") in {
"software-factory/tests",
"software-factory/review",
}
return False


def merge(number, sha):
if number < 1 or not re.fullmatch(r"[0-9a-f]{40}", sha):
raise ValueError("Merge requires a PR number and exact commit SHA")
pr = github("GET", f"/pulls/{number}")
statuses = latest_statuses(sha)
contexts = ("software-factory/tests", "software-factory/review")
check_page = github("GET", f"/commits/{sha}/check-runs?per_page=100")
checks = check_page["check_runs"]
# Refuse a stale base and incomplete pagination rather than overlooking CI.
comparison = github("GET", f"/compare/{pr['base']['sha']}...{sha}")
eligible = (
pr["state"] == "open"
and not pr["draft"]
and pr["head"]["sha"] == sha
and pr["base"]["ref"] == "main"
and re.fullmatch(r"factory/issue-\d+", pr["head"]["ref"])
and pr["mergeable"] is True
and comparison["status"] in ("ahead", "identical")
and all(statuses.get(c, {}).get("state") == "success" for c in contexts)
and all(s["state"] == "success" for s in statuses.values())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

all(s["state"] == "success" for s in statuses.values()) requires every commit status on the SHA to be "success". Any third-party tool that publishes a "pending" status will block the merge even if the factory's own acceptance statuses pass. This is intentionally conservative, but operators should be aware that any external status publisher can gate merges. Consider documenting this stricter-than-expected behavior alongside the existing CI evidence section.

and check_page.get("total_count", len(checks)) == len(checks)
and all(c["conclusion"] in ("success", "neutral", "skipped") for c in checks)
)
if not eligible:
raise ValueError("Current head lacks passing acceptance or current base")
return github(
"PUT", f"/pulls/{number}/merge", {"sha": sha, "merge_method": "squash"}
)


class Handler(BaseHTTPRequestHandler):
def log_message(self, *_):
pass

def do_POST(self):
supplied = self.headers.get("Authorization", "").removeprefix("Bearer ")
role = next(
(
r
for r in ("triage", "developer", "reviewer", "watchdog")
if secrets.compare_digest(supplied, CONTROL[r])
Comment thread
neubig marked this conversation as resolved.
Outdated
),
None,
)
if role is None:
return self.reply(401, {"error": "Invalid role credential"})
if int(self.headers.get("Content-Length", "0")) > 12_000_000:
return self.reply(413, {"error": "Request too large"})
try:
payload = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
Comment thread
neubig marked this conversation as resolved.
Outdated
method, path = payload["method"], payload["path"]
body = payload.get("body")
if "%" in path or ".." in path or "#" in path or "\\" in path:
return self.reply(403, {"error": "Invalid path"})
if path == "/factory/archive" and role in ("developer", "reviewer"):
return self.reply(200, archive(body["sha"]))
if path == "/factory/bootstrap" and role == "developer":
try:
return self.reply(200, github("GET", "/git/ref/heads/main"))
except HTTPError as exc:
if exc.code not in (404, 409):
raise
# GitHub's Git Database API rejects an empty repository. An
# empty placeholder gives the first application PR a base.
return self.reply(
200,
github(
"PUT",
"/contents/.gitkeep",
Comment thread
neubig marked this conversation as resolved.
Comment thread
neubig marked this conversation as resolved.
{
"message": "Initialize software factory repository",
"content": "",
"branch": "main",
},
),
)
if path == "/factory/merge" and role == "watchdog":
try:
result = merge(int(body["number"]), body["sha"])
except ValueError as exc:
return self.reply(409, {"error": str(exc)})
return self.reply(200, result)
if not permitted(role, method, path, body or {}):
return self.reply(403, {"error": "Operation outside role grant"})
result = github(method, path, body)
if method != "GET":
print(
json.dumps(
{
"role": role,
"method": method,
"path": path,
"body_sha256": hashlib.sha256(
json.dumps(body).encode()
).hexdigest(),
}
),
flush=True,
)
self.reply(200, result)
except HTTPError as exc:
self.reply(exc.code, {"error": exc.read().decode()[:1000]})
except (ValueError, KeyError, TypeError) as exc:
self.reply(400, {"error": str(exc)})
Comment thread
neubig marked this conversation as resolved.

def reply(self, status, data):
encoded = json.dumps(data).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)


if __name__ == "__main__":
configure()
ThreadingHTTPServer(
(os.environ.get("FACTORY_BIND", "172.17.0.1"), 19102), Handler
).serve_forever()
Loading
Loading