Compare commits
6 Commits
b13706fd97
...
43d901a6fe
| Author | SHA1 | Date | |
|---|---|---|---|
| 43d901a6fe | |||
| 0cce2bc1fe | |||
| d3c4fc0fd4 | |||
| 232dfdf37a | |||
| 9a0dd821ef | |||
| 5ad3449e3b |
@@ -25,8 +25,19 @@ jobs:
|
||||
|
||||
- name: Run pylint
|
||||
run: |
|
||||
# Run pylint on all Python files in the repo
|
||||
find . -name '*.py' -not -path './.venv/*' -not -path './.git/*' | xargs pylint --fail-under=8.0
|
||||
# Pylint's normal exit code is nonzero for any emitted finding,
|
||||
# regardless of --fail-under. Preserve the full report but enforce
|
||||
# the aggregate score this workflow promises.
|
||||
set +e
|
||||
find . -name '*.py' -not -path './.venv/*' -not -path './.git/*' \
|
||||
| xargs pylint --fail-under=8.0 \
|
||||
| tee /tmp/pylint-output.txt
|
||||
set -e
|
||||
SCORE=$(sed -n \
|
||||
's/^Your code has been rated at \([-0-9.]*\)\/10.*/\1/p' \
|
||||
/tmp/pylint-output.txt | tail -1)
|
||||
test -n "$SCORE"
|
||||
awk -v score="$SCORE" 'BEGIN { exit !(score >= 8.0) }'
|
||||
|
||||
- name: Run pyright
|
||||
run: |
|
||||
|
||||
+14
-17
@@ -34,13 +34,13 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
# No actions/setup-python: the runner image already ships Python 3.12,
|
||||
# and older act_runner engines mishandle setup-python's PATH (coverage
|
||||
# lands in one interpreter, `python3` resolves to another). Install
|
||||
# straight into the ephemeral job container's system Python —
|
||||
# --break-system-packages is safe because the container is disposable.
|
||||
- name: Install dev requirements
|
||||
run: python3 -m pip install -r requirements-dev.txt
|
||||
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
||||
|
||||
- name: Run unit tests
|
||||
run: python3 -m coverage run -m unittest discover -t . -s tests/unit -v
|
||||
@@ -54,11 +54,8 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
# No actions/setup-python (see the note in the `unit` job); the
|
||||
# container's system Python 3.12 runs the stdlib test suite directly.
|
||||
- name: Show environment
|
||||
run: |
|
||||
python3 --version
|
||||
@@ -88,13 +85,13 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
# No actions/setup-python: the runner image already ships Python 3.12,
|
||||
# and older act_runner engines mishandle setup-python's PATH (coverage
|
||||
# lands in one interpreter, `python3` resolves to another). Install
|
||||
# straight into the ephemeral job container's system Python —
|
||||
# --break-system-packages is safe because the container is disposable.
|
||||
- name: Install dev requirements
|
||||
run: python3 -m pip install -r requirements-dev.txt
|
||||
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
||||
|
||||
- name: Combined coverage report (unit + integration)
|
||||
run: PYTHON=python3 bash scripts/coverage.sh critical
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
name: tracker-policy
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, unlabeled]
|
||||
pull_request:
|
||||
types: [opened, edited, reopened, synchronized, labeled, unlabeled]
|
||||
|
||||
jobs:
|
||||
label-issue:
|
||||
if: ${{ github.event_name == 'issues' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Ensure the issue has a label
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: python3 scripts/tracker_policy.py label-issue
|
||||
|
||||
check-pr:
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Require an unlabeled PR linked to an issue
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: python3 scripts/tracker_policy.py check-pr
|
||||
+16
-27
@@ -16,10 +16,12 @@
|
||||
# Layout:
|
||||
#
|
||||
# /usr/bin/gitleaks gitleaks binary
|
||||
# /app/egress_addon.py + siblings mitmproxy addon (egress)
|
||||
# /app/egress_addon.py mitmproxy addon entry point
|
||||
# /app/egress-entrypoint.sh mitmdump launcher
|
||||
# /app/supervise_server.py + .py supervise MCP server
|
||||
# /app/gateway_init.py PID 1 supervisor
|
||||
# /usr/local/lib/python*/bot_bottle/ installed package (all daemons + shared modules)
|
||||
# /app/egress_addon.py one-line shim: re-exports addons from package
|
||||
# (mitmdump -s requires a file path, not a module)
|
||||
# /etc/egress/routes.yaml bind-mounted at run time
|
||||
# /etc/git-gate/pre-receive docker-cp'd at start time
|
||||
# /git-gate-entrypoint.sh docker-cp'd at start time
|
||||
# /git-gate/creds/* docker-cp'd at start time
|
||||
@@ -87,27 +89,16 @@ RUN arch="${TARGETARCH:-$(dpkg --print-architecture)}" \
|
||||
&& tar -xzf /tmp/gitleaks.tar.gz -C /usr/bin gitleaks \
|
||||
&& rm /tmp/gitleaks.tar.gz
|
||||
|
||||
# Project Python: addon + server modules + the init supervisor.
|
||||
# Kept flat under /app/ so mitmdump's loader resolves them as
|
||||
# top-level siblings (absolute imports), matching the prior
|
||||
# Dockerfile.egress / Dockerfile.supervise layout.
|
||||
COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
|
||||
COPY bot_bottle/egress_dlp_config.py /app/egress_dlp_config.py
|
||||
COPY bot_bottle/egress_addon.py /app/egress_addon.py
|
||||
COPY bot_bottle/policy_resolver.py /app/policy_resolver.py
|
||||
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
|
||||
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
|
||||
COPY bot_bottle/paths.py /app/paths.py
|
||||
COPY bot_bottle/migrations.py /app/migrations.py
|
||||
COPY bot_bottle/db_store.py /app/db_store.py
|
||||
COPY bot_bottle/supervise_types.py /app/supervise_types.py
|
||||
COPY bot_bottle/queue_store.py /app/queue_store.py
|
||||
COPY bot_bottle/audit_store.py /app/audit_store.py
|
||||
COPY bot_bottle/store_manager.py /app/store_manager.py
|
||||
COPY bot_bottle/supervise.py /app/supervise.py
|
||||
COPY bot_bottle/supervise_server.py /app/supervise_server.py
|
||||
COPY bot_bottle/gateway_init.py /app/gateway_init.py
|
||||
COPY bot_bottle/git_http_backend.py /app/git_http_backend.py
|
||||
# Install bot_bottle as a proper package so entry-point scripts can use
|
||||
# `from bot_bottle.X import Y` absolute imports. A rename or a missing
|
||||
# module is caught at pip-install time — not at container runtime.
|
||||
COPY pyproject.toml /src/
|
||||
COPY bot_bottle/ /src/bot_bottle/
|
||||
RUN pip install --no-cache-dir /src/
|
||||
|
||||
# mitmdump -s requires a file path, not a module. Write a one-line shim that
|
||||
# re-exports `addons` from the installed package; mitmdump finds it there.
|
||||
RUN printf 'from bot_bottle.egress_addon import addons\n' > /app/egress_addon.py
|
||||
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
|
||||
RUN chmod +x /app/egress-entrypoint.sh
|
||||
|
||||
@@ -126,10 +117,8 @@ RUN mkdir -p \
|
||||
# subset the bottle uses.
|
||||
EXPOSE 8888 9099 9418 9420 9100
|
||||
|
||||
# WORKDIR matches Dockerfile.supervise's prior layout so the
|
||||
# in-app same-dir import in supervise_server.py stays deterministic.
|
||||
WORKDIR /app
|
||||
|
||||
# PID 1 is the supervisor. It owns signal handling and exit-code
|
||||
# propagation; no `exec` chain in the entrypoint itself.
|
||||
ENTRYPOINT ["python3", "/app/gateway_init.py"]
|
||||
ENTRYPOINT ["python3", "-m", "bot_bottle.gateway_init"]
|
||||
|
||||
@@ -171,6 +171,15 @@ When an outbound DLP detector matches a token, the route's `dlp.outbound_on_matc
|
||||
|
||||
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
|
||||
|
||||
## Tracker policy
|
||||
|
||||
Issues are the canonical work items and own all tracker labels; every issue
|
||||
must have at least one. Pull requests stay unlabeled and deliberately reference
|
||||
an issue with `Closes #…`, `Part of #…`, or another form defined in
|
||||
[`ADR 0005`](docs/decisions/0005-issues-own-tracker-metadata.md). Gitea Actions
|
||||
enforces the convention for new work from 2026-07-18 onward. Earlier closed
|
||||
PRs are grandfathered rather than given artificial retrospective issues.
|
||||
|
||||
## Trademarks
|
||||
|
||||
bot-bottle is an independent project and is not affiliated with, endorsed by, or sponsored by Anthropic, PBC. "Claude" and "Claude Code" are trademarks of Anthropic, PBC; the project name uses "claude" descriptively to indicate that the tool runs Claude Code inside a sandbox.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Shared wire-protocol constants for gateway-bundled modules.
|
||||
|
||||
Single source of truth for values that appear across the egress addon,
|
||||
git-http backend, supervise server, and git-gate renderer. Importing
|
||||
from this module instead of duplicating the literals means a rename is
|
||||
a one-line change and is caught by the type checker at the import site."""
|
||||
|
||||
# App-layer identity token header. Delivered as proxy credentials
|
||||
# (HTTPS_PROXY=http://<bottle_id>:<token>@gw) by launch; the egress
|
||||
# addon reads and strips it, the supervise server and git-http backend
|
||||
# read it for attribution, and none of them forward it upstream.
|
||||
IDENTITY_HEADER = "x-bot-bottle-identity"
|
||||
|
||||
# Shared timeout (seconds) for all git-gate subprocess and CGI calls:
|
||||
# git daemon (--timeout/--init-timeout), the access-hook subprocess in
|
||||
# git_http_backend, and the git http-backend CGI subprocess.
|
||||
GIT_GATE_TIMEOUT_SECS = 15
|
||||
@@ -3,9 +3,8 @@
|
||||
Pure Python, no mitmproxy dependency. Each detector is a module-level
|
||||
function returning `ScanResult | None`.
|
||||
|
||||
Ships flat into the gateway image alongside
|
||||
`egress_addon_core.py` — both this file and the package source use
|
||||
the same try/except import shim pattern.
|
||||
Available in the gateway via the installed `bot_bottle` package
|
||||
(see `Dockerfile.gateway`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,10 +19,7 @@ from math import log2
|
||||
from collections import Counter
|
||||
from urllib.parse import quote as url_quote
|
||||
|
||||
try:
|
||||
from egress_addon_core import ScanResult # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover - host-side path
|
||||
from .egress_addon_core import ScanResult
|
||||
from .egress_addon_core import ScanResult
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -15,7 +15,9 @@ import typing
|
||||
|
||||
from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error
|
||||
|
||||
from egress_addon_core import ( # type: ignore[import-not-found] # pylint: disable=import-error
|
||||
from bot_bottle.constants import IDENTITY_HEADER
|
||||
from bot_bottle.dlp_detectors import redact_tokens, strip_crlf
|
||||
from bot_bottle.egress_addon_core import (
|
||||
LOG_BLOCKS,
|
||||
LOG_FULL,
|
||||
DEFAULT_OUTBOUND_ON_MATCH,
|
||||
@@ -38,24 +40,8 @@ from egress_addon_core import ( # type: ignore[import-not-found] # pylint: dis
|
||||
scan_inbound,
|
||||
scan_outbound,
|
||||
)
|
||||
|
||||
try:
|
||||
from dlp_detectors import redact_tokens, strip_crlf # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover - host-side path
|
||||
from bot_bottle.dlp_detectors import ( # type: ignore[import-not-found]
|
||||
redact_tokens,
|
||||
strip_crlf,
|
||||
)
|
||||
|
||||
try:
|
||||
import supervise as _sv # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover - host-side path
|
||||
from bot_bottle import supervise as _sv # type: ignore[import-not-found]
|
||||
|
||||
try:
|
||||
from policy_resolver import PolicyResolver # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover - host-side path
|
||||
from bot_bottle.policy_resolver import PolicyResolver
|
||||
from bot_bottle import supervise as _sv
|
||||
from bot_bottle.policy_resolver import PolicyResolver
|
||||
|
||||
|
||||
INTROSPECT_HOST = "_egress.local"
|
||||
@@ -66,13 +52,6 @@ INTROSPECT_HOST = "_egress.local"
|
||||
# back to — so an unset value is a fatal misconfiguration (see __init__).
|
||||
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
|
||||
|
||||
# App-layer identity token. Delivered as proxy credentials
|
||||
# (`HTTPS_PROXY=http://<bottle_id>:<token>@gw`): clients honor it as part of
|
||||
# the proxy protocol without app changes, and the addon reads + strips it so
|
||||
# it never leaks upstream. The legacy `x-bot-bottle-identity` request header
|
||||
# is still stripped defensively (git-http uses that header on its own port).
|
||||
IDENTITY_HEADER = "x-bot-bottle-identity"
|
||||
|
||||
# Per-flow key under which `request()` stashes the resolved (Config, supervise
|
||||
# slug, env) so the later `response()` and `websocket_message()` hooks scan
|
||||
# against the *calling bottle's* policy — the same one the request was decided
|
||||
|
||||
@@ -6,9 +6,9 @@ exercise the parse + decision functions without depending on the
|
||||
`mitmproxy.http.HTTPFlow` API and is loaded inside the gateway
|
||||
container.
|
||||
|
||||
Imports: stdlib + `yaml_subset` (which is itself stdlib-only and
|
||||
ships flat into the gateway image alongside this file —
|
||||
see `Dockerfile.gateway`)."""
|
||||
Imports: stdlib + sibling package modules (`yaml_subset`,
|
||||
`egress_dlp_config`). Available in the gateway via the installed
|
||||
`bot_bottle` package (see `Dockerfile.gateway`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -16,36 +16,20 @@ import re
|
||||
import typing
|
||||
from dataclasses import dataclass
|
||||
|
||||
try:
|
||||
from yaml_subset import YamlSubsetError, parse_yaml_subset # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover - host-side path
|
||||
from .yaml_subset import YamlSubsetError, parse_yaml_subset
|
||||
from .yaml_subset import YamlSubsetError, parse_yaml_subset
|
||||
|
||||
# DLP detector-config parsing lives in a sibling module (also flat-bundled
|
||||
# into the gateway — see Dockerfile.gateway). Re-exported below so existing
|
||||
# `from egress_addon_core import ON_MATCH_*` callers keep working.
|
||||
try:
|
||||
from egress_dlp_config import ( # type: ignore[import-not-found]
|
||||
DEFAULT_OUTBOUND_ON_MATCH,
|
||||
INBOUND_DETECTOR_NAMES,
|
||||
ON_MATCH_BLOCK,
|
||||
ON_MATCH_REDACT,
|
||||
ON_MATCH_SUPERVISE,
|
||||
OUTBOUND_DETECTOR_NAMES,
|
||||
OUTBOUND_ON_MATCH_VALUES,
|
||||
parse_dlp_block,
|
||||
)
|
||||
except ImportError: # pragma: no cover - host-side path
|
||||
from .egress_dlp_config import (
|
||||
DEFAULT_OUTBOUND_ON_MATCH,
|
||||
INBOUND_DETECTOR_NAMES,
|
||||
ON_MATCH_BLOCK,
|
||||
ON_MATCH_REDACT,
|
||||
ON_MATCH_SUPERVISE,
|
||||
OUTBOUND_DETECTOR_NAMES,
|
||||
OUTBOUND_ON_MATCH_VALUES,
|
||||
parse_dlp_block,
|
||||
)
|
||||
# DLP detector-config parsing lives in a sibling module. Re-exported below
|
||||
# so existing `from egress_addon_core import ON_MATCH_*` callers keep working.
|
||||
from .egress_dlp_config import (
|
||||
DEFAULT_OUTBOUND_ON_MATCH,
|
||||
INBOUND_DETECTOR_NAMES,
|
||||
ON_MATCH_BLOCK,
|
||||
ON_MATCH_REDACT,
|
||||
ON_MATCH_SUPERVISE,
|
||||
OUTBOUND_DETECTOR_NAMES,
|
||||
OUTBOUND_ON_MATCH_VALUES,
|
||||
parse_dlp_block,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -78,8 +78,8 @@ def _env_for_daemon(name: str, base_env: dict[str, str]) -> dict[str, str]:
|
||||
_DAEMONS: tuple[_DaemonSpec, ...] = (
|
||||
_DaemonSpec("egress", ("/bin/sh", "/app/egress-entrypoint.sh")),
|
||||
_DaemonSpec("git-gate", ("/bin/sh", "/git-gate-entrypoint.sh")),
|
||||
_DaemonSpec("git-http", ("python3", "/app/git_http_backend.py")),
|
||||
_DaemonSpec("supervise", ("python3", "/app/supervise_server.py")),
|
||||
_DaemonSpec("git-http", ("python3", "-m", "bot_bottle.git_http_backend")),
|
||||
_DaemonSpec("supervise", ("python3", "-m", "bot_bottle.supervise_server")),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -14,18 +14,12 @@ import shlex
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER
|
||||
from .manifest import ManifestBottle, ManifestGitEntry
|
||||
|
||||
# Short network alias for git-gate inside the gateway. The
|
||||
# agent's `.gitconfig` insteadOf rewrites resolve through this name.
|
||||
GIT_GATE_HOSTNAME = "git-gate"
|
||||
# App-layer identity token header the agent's git sends to git-http and the
|
||||
# gateway validates (mirrors egress_addon / git_http_backend IDENTITY_HEADER).
|
||||
IDENTITY_HEADER = "x-bot-bottle-identity"
|
||||
# Shared timeout (seconds) for all git-gate subprocess and CGI calls:
|
||||
# git daemon (--timeout/--init-timeout), the access-hook subprocess in
|
||||
# git_http_backend, and the git http-backend CGI subprocess.
|
||||
GIT_GATE_TIMEOUT_SECS = 15
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -26,16 +26,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
# policy_resolver ships flat alongside this file in the gateway
|
||||
# image (see Dockerfile.gateway); the bot_bottle.* fallback is the
|
||||
# host-side / test path. Mirrors egress_addon's import shape.
|
||||
try:
|
||||
from policy_resolver import ( # type: ignore[import-not-found]
|
||||
PolicyResolveError,
|
||||
PolicyResolver,
|
||||
)
|
||||
except ImportError: # pragma: no cover - host-side path
|
||||
from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver
|
||||
from bot_bottle.constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER
|
||||
from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver
|
||||
|
||||
|
||||
DEFAULT_PORT = 9420
|
||||
@@ -46,12 +38,6 @@ DEFAULT_PORT = 9420
|
||||
# repo-root fallback.
|
||||
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
|
||||
|
||||
# App-layer identity token (defense-in-depth over the source-IP invariant);
|
||||
# the agent injects it, the backend reads it for attribution and never
|
||||
# forwards it to `git http-backend`. Mirrors egress_addon.IDENTITY_HEADER
|
||||
# (duplicated, not imported: egress_addon pulls in mitmproxy).
|
||||
IDENTITY_HEADER = "x-bot-bottle-identity"
|
||||
|
||||
# The base under which each bottle's `<bottle_id>` repo namespace is nested.
|
||||
DEFAULT_REPO_ROOT = "/git"
|
||||
|
||||
@@ -89,13 +75,6 @@ def resolve_sandbox_root(
|
||||
return None # bottle_id tried to escape the root → deny
|
||||
return namespace
|
||||
|
||||
# Mirrors git_gate_render.GIT_GATE_TIMEOUT_SECS. Duplicated rather than
|
||||
# imported: this module ships as a flat top-level sibling in the gateway
|
||||
# bundle image (see Dockerfile.gateway), not as part of the bot_bottle
|
||||
# package, so `bot_bottle.git_gate` and its dependency chain aren't
|
||||
# available at runtime.
|
||||
GIT_GATE_TIMEOUT_SECS = 15
|
||||
|
||||
# Bound memory use while still allowing ordinary git push packfiles.
|
||||
MAX_BODY_BYTES = 100 * 1024 * 1024
|
||||
|
||||
|
||||
@@ -22,8 +22,7 @@ closed too rather than silently serving stale or empty policy.
|
||||
|
||||
The resolved value is the policy blob the orchestrator stores verbatim; the
|
||||
consumer parses it (e.g. the egress addon's `load_config`). This module is
|
||||
stdlib-only and free of bot-bottle imports so it can be COPYed flat into
|
||||
the gateway.
|
||||
stdlib-only and free of bot-bottle imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
+16
-34
@@ -37,40 +37,22 @@ from abc import ABC
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from .supervise_types import (
|
||||
ACTION_OPERATOR_EDIT,
|
||||
AuditEntry,
|
||||
Proposal,
|
||||
Response,
|
||||
STATUSES,
|
||||
STATUS_APPROVED,
|
||||
STATUS_MODIFIED,
|
||||
STATUS_REJECTED,
|
||||
TOOLS,
|
||||
TOOL_EGRESS_ALLOW,
|
||||
TOOL_EGRESS_BLOCK,
|
||||
TOOL_EGRESS_TOKEN_ALLOW,
|
||||
TOOL_GITLEAKS_ALLOW,
|
||||
TOOL_LIST_EGRESS_ROUTES,
|
||||
)
|
||||
except ImportError:
|
||||
from supervise_types import ( # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module
|
||||
ACTION_OPERATOR_EDIT,
|
||||
AuditEntry,
|
||||
Proposal,
|
||||
Response,
|
||||
STATUSES,
|
||||
STATUS_APPROVED,
|
||||
STATUS_MODIFIED,
|
||||
STATUS_REJECTED,
|
||||
TOOLS,
|
||||
TOOL_EGRESS_ALLOW,
|
||||
TOOL_EGRESS_BLOCK,
|
||||
TOOL_EGRESS_TOKEN_ALLOW,
|
||||
TOOL_GITLEAKS_ALLOW,
|
||||
TOOL_LIST_EGRESS_ROUTES,
|
||||
)
|
||||
from .supervise_types import (
|
||||
ACTION_OPERATOR_EDIT,
|
||||
AuditEntry,
|
||||
Proposal,
|
||||
Response,
|
||||
STATUSES,
|
||||
STATUS_APPROVED,
|
||||
STATUS_MODIFIED,
|
||||
STATUS_REJECTED,
|
||||
TOOLS,
|
||||
TOOL_EGRESS_ALLOW,
|
||||
TOOL_EGRESS_BLOCK,
|
||||
TOOL_EGRESS_TOKEN_ALLOW,
|
||||
TOOL_GITLEAKS_ALLOW,
|
||||
TOOL_LIST_EGRESS_ROUTES,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
|
||||
@@ -26,9 +26,8 @@ Speaks MCP over HTTP+JSON-RPC. Methods handled:
|
||||
|
||||
Everything else returns JSON-RPC error -32601 (method not found).
|
||||
|
||||
Stdlib-only. The Dockerfile copies this file + bot_bottle/supervise.py
|
||||
into the image; the server imports `supervise` for the queue / Proposal
|
||||
plumbing.
|
||||
The Dockerfile copies this script to /app/supervise_server.py and installs
|
||||
the bot_bottle package so its `from bot_bottle.*` imports resolve.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -42,29 +41,18 @@ import time
|
||||
import typing
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
try:
|
||||
# Same-directory imports inside the bundle container; these files are
|
||||
# COPYed flat under /app by Dockerfile.gateway.
|
||||
from egress_addon_core import (
|
||||
LOG_OFF, load_config, resolve_client_context, route_to_yaml_dict,
|
||||
)
|
||||
from policy_resolver import PolicyResolveError, PolicyResolver
|
||||
import supervise as _sv
|
||||
except ModuleNotFoundError:
|
||||
# Package imports for host-side tests and tooling.
|
||||
from .egress_addon_core import (
|
||||
LOG_OFF, load_config, resolve_client_context, route_to_yaml_dict,
|
||||
)
|
||||
from .policy_resolver import PolicyResolveError, PolicyResolver
|
||||
from . import supervise as _sv
|
||||
from bot_bottle.constants import IDENTITY_HEADER
|
||||
from bot_bottle.egress_addon_core import (
|
||||
LOG_OFF, load_config, resolve_client_context, route_to_yaml_dict,
|
||||
)
|
||||
from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver
|
||||
from bot_bottle import supervise as _sv
|
||||
|
||||
|
||||
# --- JSON-RPC / MCP plumbing ----------------------------------------------
|
||||
|
||||
|
||||
MCP_PROTOCOL_VERSION = "2024-11-05"
|
||||
# App-layer identity token header (mirrors egress_addon / git_http_backend).
|
||||
IDENTITY_HEADER = "x-bot-bottle-identity"
|
||||
SERVER_NAME = "bot-bottle-supervise"
|
||||
SERVER_VERSION = "0.1.0"
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# ADR 0005: Keep tracker metadata on issues
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-18
|
||||
- **Deciders:** didericis
|
||||
|
||||
## Context
|
||||
|
||||
Gitea exposes labels on both issues and pull requests. Applying the same labels
|
||||
to both copies planning metadata, creates a synchronization obligation, and
|
||||
makes disagreements between the two records possible. At the same time,
|
||||
unlabelled objects look accidental unless the repository states which object
|
||||
owns the metadata.
|
||||
|
||||
The repository already uses issues as work items and PRs as implementations of
|
||||
those work items. At this decision's cutoff, all open PRs reference issues, but
|
||||
121 of 219 historically merged PRs do not. Manufacturing retrospective issues
|
||||
for that history would create records that never participated in planning and
|
||||
would make the issue history less truthful.
|
||||
|
||||
## Decision
|
||||
|
||||
Issues are the canonical tracker records and own labels. Every issue has at
|
||||
least one label. An issue opened or left without labels receives
|
||||
`Status/Needs Triage` automatically until it is classified.
|
||||
|
||||
Pull requests carry no labels. Every new PR deliberately references at least
|
||||
one existing issue in its title or description with one of these forms:
|
||||
|
||||
- `Closes #123`, `Fixes #123`, or `Resolves #123` when merging completes it.
|
||||
- `Part of #123`, `Related to #123`, `Refs #123`, or `References #123` when it
|
||||
contributes without completing it.
|
||||
|
||||
Gitea Actions enforces both PR rules as a status check and repairs the empty
|
||||
issue-label state. Branch protection makes the PR policy check required.
|
||||
|
||||
The policy applies from 2026-07-18 onward. Existing issues may be labelled as
|
||||
they are encountered, but closed PRs are grandfathered: no retrospective
|
||||
issues or PR labels are created solely to make history conform.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Classification, priority, and workflow metadata have one source of truth.
|
||||
- A PR's issue link is the navigation path to its planning metadata.
|
||||
- Multi-PR issues do not require copied or synchronized labels.
|
||||
- `Status/Needs Triage` is an intentional fallback, not a final
|
||||
classification.
|
||||
- Direct issue creation remains convenient; automation repairs a missing label
|
||||
immediately after creation because Gitea has no native required-label rule.
|
||||
- The required check must be configured in branch protection after this
|
||||
workflow lands.
|
||||
|
||||
## Links
|
||||
|
||||
- Issue #405.
|
||||
- `.gitea/workflows/tracker-policy.yml`.
|
||||
- `scripts/tracker_policy.py`.
|
||||
@@ -0,0 +1,8 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.backends.legacy:build"
|
||||
|
||||
[project]
|
||||
name = "bot-bottle"
|
||||
version = "0.0.0"
|
||||
requires-python = ">=3.11"
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enforce the repository's issue/PR metadata policy in Gitea Actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ISSUE_REFERENCE = re.compile(
|
||||
r"(?im)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|part\s+of|"
|
||||
r"related\s+to|refs?|references)\s+#(\d+)\b"
|
||||
)
|
||||
TRIAGE_LABEL = "Status/Needs Triage"
|
||||
|
||||
|
||||
def deliberate_issue_numbers(title: str, body: str) -> set[int]:
|
||||
"""Return same-repository issue numbers referenced intentionally."""
|
||||
return {int(match) for match in ISSUE_REFERENCE.findall(f"{title}\n{body}")}
|
||||
|
||||
|
||||
class GiteaApi:
|
||||
"""Small API client using the Actions-provided repository token."""
|
||||
|
||||
def __init__(self, api_url: str, repository: str, token: str) -> None:
|
||||
self.base = f"{api_url.rstrip('/')}/repos/{repository}"
|
||||
self.token = token
|
||||
|
||||
def request(self, method: str, path: str, payload: object | None = None) -> Any:
|
||||
data = None if payload is None else json.dumps(payload).encode()
|
||||
request = urllib.request.Request(
|
||||
f"{self.base}{path}",
|
||||
data=data,
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": f"token {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
if response.status == 204:
|
||||
return None
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def check_pull_request(event: dict[str, Any], api: GiteaApi) -> list[str]:
|
||||
"""Return policy violations for a pull_request event."""
|
||||
pull = event["pull_request"]
|
||||
errors: list[str] = []
|
||||
labels = pull.get("labels") or []
|
||||
if labels:
|
||||
errors.append(
|
||||
"PRs must be unlabeled; put tracker metadata on the linked issue "
|
||||
f"(found: {', '.join(label['name'] for label in labels)})."
|
||||
)
|
||||
|
||||
numbers = deliberate_issue_numbers(pull.get("title", ""), pull.get("body", ""))
|
||||
if not numbers:
|
||||
errors.append(
|
||||
"PR must reference an issue with Closes/Fixes/Resolves #N, "
|
||||
"Part of #N, Related to #N, Refs #N, or References #N."
|
||||
)
|
||||
return errors
|
||||
|
||||
real_issues = 0
|
||||
for number in sorted(numbers):
|
||||
try:
|
||||
item = api.request("GET", f"/issues/{number}")
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code == 404:
|
||||
errors.append(f"Referenced issue #{number} does not exist.")
|
||||
continue
|
||||
raise
|
||||
if item.get("pull_request") is not None:
|
||||
errors.append(f"#{number} is a pull request, not an issue.")
|
||||
else:
|
||||
real_issues += 1
|
||||
|
||||
if not real_issues and not errors:
|
||||
errors.append("PR must reference at least one real issue.")
|
||||
return errors
|
||||
|
||||
|
||||
def ensure_issue_label(event: dict[str, Any], api: GiteaApi) -> bool:
|
||||
"""Apply the triage label if an issue event leaves the issue unlabeled."""
|
||||
issue = event["issue"]
|
||||
if issue.get("pull_request") is not None or issue.get("labels"):
|
||||
return False
|
||||
labels = api.request("GET", "/labels?limit=100")
|
||||
triage = next((label for label in labels if label["name"] == TRIAGE_LABEL), None)
|
||||
if triage is None:
|
||||
raise RuntimeError(f"repository label {TRIAGE_LABEL!r} does not exist")
|
||||
api.request("POST", f"/issues/{issue['number']}/labels", {"labels": [triage["id"]]})
|
||||
return True
|
||||
|
||||
|
||||
def _load_event(path: str) -> dict[str, Any]:
|
||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("command", choices=("check-pr", "label-issue"))
|
||||
parser.add_argument("--event", default=os.environ.get("GITHUB_EVENT_PATH"))
|
||||
args = parser.parse_args()
|
||||
if not args.event:
|
||||
parser.error("--event or GITHUB_EVENT_PATH is required")
|
||||
|
||||
api = GiteaApi(
|
||||
os.environ["GITHUB_API_URL"],
|
||||
os.environ["GITHUB_REPOSITORY"],
|
||||
os.environ["GITHUB_TOKEN"],
|
||||
)
|
||||
event = _load_event(args.event)
|
||||
if args.command == "check-pr":
|
||||
errors = check_pull_request(event, api)
|
||||
if errors:
|
||||
print("\n".join(f"::error::{error}" for error in errors))
|
||||
return 1
|
||||
print("PR tracker policy passed.")
|
||||
return 0
|
||||
|
||||
changed = ensure_issue_label(event, api)
|
||||
print(f"Applied {TRIAGE_LABEL}." if changed else "Issue already has a label.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -18,7 +18,7 @@ from unittest.mock import patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway-import shims — must run before importing egress_addon
|
||||
# mitmproxy stub — must run before importing egress_addon
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ensure_shims() -> None:
|
||||
@@ -32,9 +32,6 @@ def _ensure_shims() -> None:
|
||||
setattr(_mm, "http", _mh)
|
||||
sys.modules["mitmproxy"] = _mm
|
||||
sys.modules["mitmproxy.http"] = _mh
|
||||
if "egress_addon_core" not in sys.modules:
|
||||
import bot_bottle.egress_addon_core as _core
|
||||
sys.modules["egress_addon_core"] = _core
|
||||
|
||||
|
||||
_ensure_shims()
|
||||
|
||||
@@ -190,9 +190,6 @@ def _ensure_shims() -> None:
|
||||
setattr(mh, "Response", _Response)
|
||||
if not hasattr(mh, "HTTPFlow"):
|
||||
setattr(mh, "HTTPFlow", object)
|
||||
if "egress_addon_core" not in sys.modules:
|
||||
import bot_bottle.egress_addon_core as _core
|
||||
sys.modules["egress_addon_core"] = _core
|
||||
|
||||
|
||||
_ensure_shims()
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
@@ -13,15 +12,9 @@ from unittest.mock import patch
|
||||
|
||||
from tests.unit import use_bottle_root
|
||||
|
||||
|
||||
# The server module loads `supervise` via same-directory import inside
|
||||
# the container (Dockerfile.supervise WORKDIRs into /app). For tests
|
||||
# we mirror that by injecting bot_bottle/ onto sys.path under the
|
||||
# bare name `supervise`.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "bot_bottle"))
|
||||
import supervise as _sv # noqa: E402 # type: ignore
|
||||
import queue_store as _qs # noqa: E402 # type: ignore
|
||||
import audit_store as _as # noqa: E402 # type: ignore
|
||||
from bot_bottle import supervise as _sv
|
||||
from bot_bottle import queue_store as _qs
|
||||
from bot_bottle import audit_store as _as
|
||||
|
||||
from bot_bottle import supervise_server # noqa: E402
|
||||
from bot_bottle.supervise_server import (
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
from scripts.tracker_policy import (
|
||||
TRIAGE_LABEL,
|
||||
check_pull_request,
|
||||
deliberate_issue_numbers,
|
||||
ensure_issue_label,
|
||||
)
|
||||
|
||||
|
||||
class TestDeliberateIssueNumbers(unittest.TestCase):
|
||||
def test_accepts_completing_and_noncompleting_forms(self):
|
||||
self.assertEqual(
|
||||
deliberate_issue_numbers("Fixes #12", "Part of #14; refs #15"),
|
||||
{12, 14, 15},
|
||||
)
|
||||
|
||||
def test_does_not_treat_incidental_number_as_link(self):
|
||||
self.assertEqual(deliberate_issue_numbers("Audit #12", "See PR #14"), set())
|
||||
|
||||
|
||||
class TestCheckPullRequest(unittest.TestCase):
|
||||
def test_accepts_unlabelled_pr_linked_to_real_issue(self):
|
||||
api = Mock()
|
||||
api.request.return_value = {"number": 12, "pull_request": None}
|
||||
event = {"pull_request": {"title": "Change", "body": "Part of #12", "labels": []}}
|
||||
self.assertEqual(check_pull_request(event, api), [])
|
||||
|
||||
def test_rejects_labels_and_pr_reference(self):
|
||||
api = Mock()
|
||||
api.request.return_value = {"number": 12, "pull_request": {}}
|
||||
event = {
|
||||
"pull_request": {
|
||||
"title": "Change",
|
||||
"body": "Closes #12",
|
||||
"labels": [{"name": "Kind/Bug"}],
|
||||
}
|
||||
}
|
||||
errors = check_pull_request(event, api)
|
||||
self.assertEqual(len(errors), 2)
|
||||
self.assertIn("unlabeled", errors[0])
|
||||
self.assertIn("not an issue", errors[1])
|
||||
|
||||
|
||||
class TestEnsureIssueLabel(unittest.TestCase):
|
||||
def test_adds_triage_label_to_unlabelled_issue(self):
|
||||
api = Mock()
|
||||
api.request.side_effect = [[{"id": 55, "name": TRIAGE_LABEL}], None]
|
||||
event = {"issue": {"number": 405, "labels": [], "pull_request": None}}
|
||||
self.assertTrue(ensure_issue_label(event, api))
|
||||
api.request.assert_any_call("POST", "/issues/405/labels", {"labels": [55]})
|
||||
|
||||
def test_leaves_labelled_issue_unchanged(self):
|
||||
api = Mock()
|
||||
event = {"issue": {"number": 405, "labels": [{"name": "Kind/Documentation"}]}}
|
||||
self.assertFalse(ensure_issue_label(event, api))
|
||||
api.request.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user