Skip to content
Draft
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 @@ -21,6 +21,7 @@ sheets/
.pytest_cache/
*.pyc
.coverage
.venv/

# Distribution / packaging
.Python
Expand Down
25 changes: 13 additions & 12 deletions docs/hackbot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,19 @@ what it proposed.

## Where to read next

| If you want to… | Read |
| ---------------------------------------------------------- | ---------------------------------- |
| Understand the components and why they're split that way | [architecture.md](architecture.md) |
| Write or modify an agent | [agents.md](agents.md) |
| Know what the runtime hands your agent | [runtime.md](runtime.md) |
| Find a tool your agent can call, or add one | [tools.md](tools.md) |
| Understand how agents change the world (record-then-apply) | [actions.md](actions.md) |
| Work on the control-plane service | [api.md](api.md) |
| Know how runs get started | [triggers.md](triggers.md) |
| Reason about credentials and trust boundaries | [security.md](security.md) |
| Deploy, configure, or run things locally | [deployment.md](deployment.md) |
| Look at traces of a run | [tracing.md](tracing.md) |
| If you want to… | Read |
| ---------------------------------------------------------- | -------------------------------------- |
| Understand the components and why they're split that way | [architecture.md](architecture.md) |
| Write or modify an agent | [agents.md](agents.md) |
| Know what the runtime hands your agent | [runtime.md](runtime.md) |
| Find a tool your agent can call, or add one | [tools.md](tools.md) |
| Understand how agents change the world (record-then-apply) | [actions.md](actions.md) |
| Work on the control-plane service | [api.md](api.md) |
| Know how runs get started | [triggers.md](triggers.md) |
| Reason about credentials and trust boundaries | [security.md](security.md) |
| Follow the proposed private-Bugzilla access design | [bugzilla-proxy.md](bugzilla-proxy.md) |
| Deploy, configure, or run things locally | [deployment.md](deployment.md) |
| Look at traces of a run | [tracing.md](tracing.md) |

## Code map

Expand Down
410 changes: 410 additions & 0 deletions docs/hackbot/bugzilla-proxy.md

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions docs/hackbot/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ Today `bug-fix`, `build-repair`, `frontend-triage` and `autowebcompat-repro` run
`test-plan-generator` needs no credentialed reads at all. The invariant holds in every case:
**the key is never in the agent container.**

### Bugzilla reads are moving behind a proxy

A broker holding a Bugzilla key can read whatever that account can, for every run of that
agent. [bugzilla-proxy](bugzilla-proxy.md) replaces the key with a per-run capability token:
the credential moves to one shared service, and the broker holds only a signed statement of
what this run may read. The service and the minting exist; **no agent uses them yet**, and
every broker still holds its own key until the service is deployed and tested.

This is what makes private bugs reachable later, under a scope narrow enough to review. The
containment that has to land first is in [bugzilla-proxy.md](bugzilla-proxy.md).

## Workload Identity Federation

Anthropic and W&B both accept a Google-signed OIDC identity token exchanged for a
Expand Down
35 changes: 35 additions & 0 deletions services/bugzilla-proxy/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
FROM python:3.14-slim AS builder

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

ENV UV_PROJECT_ENVIRONMENT=/opt/venv

WORKDIR /app

# Install external deps without building workspace members.
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=VERSION,target=VERSION \
uv sync --frozen --no-dev --no-install-workspace --package bugzilla-proxy

RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,target=/app,rw \
uv sync --locked --no-dev --no-editable --package bugzilla-proxy

FROM python:3.14-slim AS base

COPY --from=builder /opt/venv /opt/venv
WORKDIR /app

ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV PORT=8080
ENV PATH="/opt/venv/bin:$PATH"

RUN useradd --create-home --shell /bin/bash app
USER app

EXPOSE 8080

CMD ["uvicorn", "bugzilla_proxy.main:app", "--host", "0.0.0.0", "--port", "8080"]
90 changes: 90 additions & 0 deletions services/bugzilla-proxy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# bugzilla-proxy

An authorization proxy for the Bugzilla REST API. It holds the upstream
credential; callers present a per-run capability token saying what they may
read.

Design and rollout plan: [docs/hackbot/bugzilla-proxy.md](../../docs/hackbot/bugzilla-proxy.md).

## What it does

- Verifies an RS256 capability token minted by hackbot-api, against the public
certificates Google publishes for hackbot-api's service account (or a static
PEM locally). No key material is provisioned or exchanged between the two
services: the shared configuration is one email address.
- Exposes exactly four read endpoints. Everything else, and every write, is a
Bugzilla-shaped 101 "endpoint not exposed".
- Decides access per bug from the bug's own fields, fetched with the proxy's
credential. A bug in a security group is denied unless the token names that
group, and a bug whose groups it cannot see is denied outright.
- Projects each bug down to the fields the token's tier exposes.
- Logs every decision with the run, agent and requester behind it.

## What it does not do yet

Phase 0 of the plan. Not implemented: content filtering (`filter_content` other
than `off` is refused rather than silently ignored), upstream query rewriting
(search results are filtered after they arrive, so a capped search can return
fewer rows than the caller asked for), and promotion between tiers.

## Endpoints

| Path | Serves |
| ------------------------------- | ------------------------------------- |
| `GET /rest/bug` | Search, and bulk fetch by `id` |
| `GET /rest/bug/{id}/comment` | A bug's comments |
| `GET /rest/bug/{id}/attachment` | A bug's attachments |
| `GET /rest/bug/attachment/{id}` | One attachment, authorized by its bug |
| `GET /healthz` | Liveness, no token required |

## Configuration

All settings take a `BUGZILLA_PROXY_` prefix.

| Variable | Purpose |
| ---------------------------- | ----------------------------------------------------------------- |
| `UPSTREAM_URL` | Bugzilla REST base URL |
| `UPSTREAM_API_KEY` | The credential, from Secret Manager |
| `TOKEN_ISSUER` | hackbot-api's service account email, whose certs verify the token |
| `JWT_PUBLIC_KEY` | A PEM instead, for local runs. Exactly one of these two |
| `DECISION_CACHE_TTL_SECONDS` | How long bug metadata is reused (default 300) |
| `MAX_SEARCH_LIMIT` | Ceiling on rows fetched per search (default 500) |

Deployed, `TOKEN_ISSUER` is the whole trust configuration. The certificate URL is
derived from it, so only that account's keys are ever candidates for verifying a
token.

Two values are deliberately **not** configurable, and live as constants in
`bugzilla_proxy/tokens.py` instead:

- `TOKEN_AUDIENCE`, which must match what hackbot-api mints, and which would
make the token a Google credential if pointed at a Google endpoint.
- `CERTS_URL_TEMPLATE`, which decides whose signatures we accept. Pointing it
elsewhere would let whoever answers it authenticate as any run.

Neither varies legitimately, so neither is something a deploy can get wrong. The
design doc covers the reasoning under "Why this is not a Google credential".

## Running locally

```bash
uv sync --extra dev --package bugzilla-proxy
openssl genrsa -out /tmp/bzproxy.pem 2048
openssl rsa -in /tmp/bzproxy.pem -pubout -out /tmp/bzproxy.pub

BUGZILLA_PROXY_UPSTREAM_API_KEY=... \
BUGZILLA_PROXY_JWT_PUBLIC_KEY="$(cat /tmp/bzproxy.pub)" \
uv run --package bugzilla-proxy python -m bugzilla_proxy.main
```

Point hackbot-api at the same keypair with `BZ_TOKEN_PRIVATE_KEY`, and a broker
at the proxy with `BUGZILLA_PROXY_URL` plus `BUGZILLA_PROXY_AUDIENCE=""` (the
local proxy is not behind IAM, so there is no identity token to present).

## Tests

```bash
uv run --package bugzilla-proxy pytest
```

`tests/test_scope.py` is the one to read first: it is where the refusals live.
Empty file.
Loading