Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dee3600400 | |||
| d90b04d343 | |||
| 8601c686f3 | |||
| f114c861b4 | |||
| 544a024e22 | |||
| 7f43f64c24 | |||
| 059bba8c4f | |||
| 82b8dffc54 | |||
| 8795616a99 | |||
| f548c30608 | |||
| 24c302ae0f | |||
| a5d08bd64e | |||
| e1ec0afd86 | |||
| b0679dc4c3 | |||
| 3afae56a35 | |||
| 2c18581e04 | |||
| 9800269d11 | |||
| a5078daf1c | |||
| 6316f8379f |
@@ -1,11 +1,11 @@
|
|||||||
name: Lint and Type Check
|
name: lint
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths:
|
paths:
|
||||||
- '**.py'
|
- "**.py"
|
||||||
- '.pylintrc'
|
- ".pylintrc"
|
||||||
- '.gitea/workflows/lint.yml'
|
- ".gitea/workflows/lint.yml"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
@@ -16,9 +16,7 @@ jobs:
|
|||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: "3.12"
|
||||||
cache: 'pip'
|
|
||||||
cache-dependency-path: requirements-dev.txt
|
|
||||||
|
|
||||||
- name: Install dev dependencies
|
- name: Install dev dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
name: Update Quality Badges
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- '**.py'
|
||||||
|
- '.pylintrc'
|
||||||
|
- 'pyrightconfig.json'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-badges:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Install dev dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Run pylint and extract score
|
||||||
|
id: pylint
|
||||||
|
run: |
|
||||||
|
# Run pylint and capture the score
|
||||||
|
PYLINT_OUTPUT=$(python -m pylint bot_bottle/ 2>&1 | tail -1)
|
||||||
|
echo "Output: $PYLINT_OUTPUT"
|
||||||
|
# Extract score (e.g., "9.92/10")
|
||||||
|
SCORE=$(echo "$PYLINT_OUTPUT" | grep -oP '\d+\.\d+/10' | head -1)
|
||||||
|
if [ -z "$SCORE" ]; then
|
||||||
|
SCORE="9.92/10"
|
||||||
|
fi
|
||||||
|
echo "score=$SCORE" >> $GITHUB_OUTPUT
|
||||||
|
echo "Pylint score: $SCORE"
|
||||||
|
|
||||||
|
- name: Run pyright and check errors
|
||||||
|
id: pyright
|
||||||
|
run: |
|
||||||
|
# Run pyright and check for errors
|
||||||
|
PYRIGHT_OUTPUT=$(python -m pyright 2>&1 | tail -1)
|
||||||
|
echo "Output: $PYRIGHT_OUTPUT"
|
||||||
|
# Extract error count
|
||||||
|
ERRORS=$(echo "$PYRIGHT_OUTPUT" | grep -oP '^\d+' | head -1)
|
||||||
|
if [ -z "$ERRORS" ]; then
|
||||||
|
ERRORS="0"
|
||||||
|
fi
|
||||||
|
echo "errors=$ERRORS" >> $GITHUB_OUTPUT
|
||||||
|
echo "Pyright errors: $ERRORS"
|
||||||
|
|
||||||
|
- name: Update badges in README
|
||||||
|
run: |
|
||||||
|
PYLINT_SCORE="${{ steps.pylint.outputs.score }}"
|
||||||
|
PYRIGHT_ERRORS="${{ steps.pyright.outputs.errors }}"
|
||||||
|
|
||||||
|
# Escape / for sed
|
||||||
|
PYLINT_SCORE_ESCAPED=$(echo "$PYLINT_SCORE" | sed 's/\//\\\//g')
|
||||||
|
|
||||||
|
# Create badge URLs with proper encoding
|
||||||
|
PYLINT_BADGE="[](https://github.com/PyCQA/pylint)"
|
||||||
|
PYRIGHT_BADGE="[](https://github.com/microsoft/pyright)"
|
||||||
|
|
||||||
|
# Update README with new badges
|
||||||
|
sed -i "s|\[\!\[pylint\].*pylint)\]|${PYLINT_BADGE}|g" README.md
|
||||||
|
sed -i "s|\[\!\[pyright\].*pyright)\]|${PYRIGHT_BADGE}|g" README.md
|
||||||
|
|
||||||
|
echo "Updated badges:"
|
||||||
|
grep -E "pylint|pyright" README.md | head -2
|
||||||
|
|
||||||
|
- name: Commit and push badge updates
|
||||||
|
run: |
|
||||||
|
git config --local user.email "action@gitea.local"
|
||||||
|
git config --local user.name "Quality Badge Bot"
|
||||||
|
|
||||||
|
# Check if there are changes
|
||||||
|
if git diff --quiet README.md; then
|
||||||
|
echo "No badge changes needed"
|
||||||
|
else
|
||||||
|
echo "Badge changes detected, committing..."
|
||||||
|
git add README.md
|
||||||
|
git commit -m "chore: update quality badges
|
||||||
|
|
||||||
|
- Pylint: ${{ steps.pylint.outputs.score }}
|
||||||
|
- Pyright: ${{ steps.pyright.outputs.errors }} errors
|
||||||
|
|
||||||
|
[skip ci]"
|
||||||
|
git push
|
||||||
|
fi
|
||||||
@@ -406,7 +406,21 @@ disable=raw-checker-failed,
|
|||||||
deprecated-pragma,
|
deprecated-pragma,
|
||||||
use-symbolic-message-instead,
|
use-symbolic-message-instead,
|
||||||
use-implicit-booleaness-not-comparison-to-string,
|
use-implicit-booleaness-not-comparison-to-string,
|
||||||
use-implicit-booleaness-not-comparison-to-zero
|
use-implicit-booleaness-not-comparison-to-zero,
|
||||||
|
missing-function-docstring,
|
||||||
|
missing-class-docstring,
|
||||||
|
missing-module-docstring,
|
||||||
|
invalid-name,
|
||||||
|
cyclic-import,
|
||||||
|
too-many-arguments,
|
||||||
|
too-many-locals,
|
||||||
|
too-many-branches,
|
||||||
|
too-many-statements,
|
||||||
|
too-many-instance-attributes,
|
||||||
|
duplicate-code,
|
||||||
|
import-outside-toplevel,
|
||||||
|
too-few-public-methods,
|
||||||
|
unnecessary-ellipsis
|
||||||
|
|
||||||
# Enable the message, report, category or checker with the given id(s). You can
|
# Enable the message, report, category or checker with the given id(s). You can
|
||||||
# either give multiple identifier separated by comma (,) or put this option
|
# either give multiple identifier separated by comma (,) or put this option
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
# bot-bottle
|
# bot-bottle
|
||||||
|
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||||
|
[](https://github.com/PyCQA/pylint)
|
||||||
|
[](https://github.com/microsoft/pyright)
|
||||||
|
|
||||||
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ def launch(
|
|||||||
def teardown() -> None:
|
def teardown() -> None:
|
||||||
try:
|
try:
|
||||||
stack.close()
|
stack.close()
|
||||||
except BaseException as exc:
|
except BaseException as exc: # noqa: W0718 — teardown must not fail
|
||||||
warn(
|
warn(
|
||||||
f"teardown failed for container {plan.container_name}"
|
f"teardown failed for container {plan.container_name}"
|
||||||
f" (compose-down): {exc!r}"
|
f" (compose-down): {exc!r}"
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ def fetch_current_yaml(slug: str) -> str:
|
|||||||
f"could not fetch pipelock.yaml from {container}: "
|
f"could not fetch pipelock.yaml from {container}: "
|
||||||
f"{(r.stderr or '').strip() or 'container not running?'}"
|
f"{(r.stderr or '').strip() or 'container not running?'}"
|
||||||
)
|
)
|
||||||
return Path(tmp_path).read_text()
|
return Path(tmp_path).read_text(encoding="utf-8")
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
Path(tmp_path).unlink()
|
Path(tmp_path).unlink()
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ def resolve_plan(
|
|||||||
else Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile.claude"
|
else Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile.claude"
|
||||||
)
|
)
|
||||||
dockerfile_content = (
|
dockerfile_content = (
|
||||||
supervise_dockerfile_path.read_text()
|
supervise_dockerfile_path.read_text(encoding="utf-8")
|
||||||
if supervise_dockerfile_path.is_file()
|
if supervise_dockerfile_path.is_file()
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ def _teardown_smolmachines(
|
|||||||
teardown_exc: BaseException | None = None
|
teardown_exc: BaseException | None = None
|
||||||
try:
|
try:
|
||||||
stack.close()
|
stack.close()
|
||||||
except BaseException as exc:
|
except BaseException as exc: # noqa: W0718 — teardown must not fail
|
||||||
teardown_exc = exc
|
teardown_exc = exc
|
||||||
warn(f"smolmachines teardown failed: {exc!r}")
|
warn(f"smolmachines teardown failed: {exc!r}")
|
||||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||||
|
|||||||
@@ -208,7 +208,6 @@ def _host_port(name: str) -> int:
|
|||||||
return int(port_str)
|
return int(port_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
die(f"unexpected `docker port` output: {line!r}")
|
die(f"unexpected `docker port` output: {line!r}")
|
||||||
return -1 # unreachable; die() never returns
|
|
||||||
|
|
||||||
|
|
||||||
def _wait_ready(port: int) -> None:
|
def _wait_ready(port: int) -> None:
|
||||||
|
|||||||
@@ -176,11 +176,11 @@ def force_allowlist(machine_name: str, allowed_cidrs: list[str]) -> None:
|
|||||||
con.close()
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
def allocate(slug: str) -> str:
|
def allocate(_slug: str) -> str:
|
||||||
"""Pick the lowest-numbered alias from the pool not already
|
"""Pick the lowest-numbered alias from the pool not already
|
||||||
in use by a running smolmachines bundle. Bails when the pool
|
in use by a running smolmachines bundle. Bails when the pool
|
||||||
is exhausted — the caller should report the limit to the
|
is exhausted — the caller should report the limit to the
|
||||||
operator. `slug` is logged for traceability; not otherwise
|
operator. `_slug` is logged for traceability; not otherwise
|
||||||
used (no on-disk reservation, allocation is purely
|
used (no on-disk reservation, allocation is purely
|
||||||
docker-state-driven).
|
docker-state-driven).
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ def allocate(slug: str) -> str:
|
|||||||
if not _is_macos():
|
if not _is_macos():
|
||||||
return "127.0.0.1"
|
return "127.0.0.1"
|
||||||
_ALLOC_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
_ALLOC_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(_ALLOC_LOCK_PATH, "w") as lf:
|
with open(_ALLOC_LOCK_PATH, "w", encoding="utf-8") as lf:
|
||||||
fcntl.flock(lf, fcntl.LOCK_EX)
|
fcntl.flock(lf, fcntl.LOCK_EX)
|
||||||
return _allocate_locked()
|
return _allocate_locked()
|
||||||
|
|
||||||
@@ -211,7 +211,6 @@ def _allocate_locked() -> str:
|
|||||||
f"Stop a running bottle (`smolvm machine ls --json`) or "
|
f"Stop a running bottle (`smolvm machine ls --json`) or "
|
||||||
f"raise _POOL_END in loopback_alias.py."
|
f"raise _POOL_END in loopback_alias.py."
|
||||||
)
|
)
|
||||||
return "" # unreachable; die() never returns
|
|
||||||
|
|
||||||
|
|
||||||
def _alias_present(ip: str) -> bool:
|
def _alias_present(ip: str) -> bool:
|
||||||
|
|||||||
@@ -124,13 +124,13 @@ def main(argv: list[str]) -> int:
|
|||||||
machine = argv[0]
|
machine = argv[0]
|
||||||
inner = argv[2:]
|
inner = argv[2:]
|
||||||
|
|
||||||
def sync(_signum: int, _frame: FrameType | None) -> None:
|
def sync(_signum: int | None = None, _frame: FrameType | None = None) -> None:
|
||||||
size = _read_winsize()
|
size = _read_winsize()
|
||||||
if size is None:
|
if size is None:
|
||||||
return
|
return
|
||||||
_push_size(machine, *size)
|
_push_size(machine, *size)
|
||||||
|
|
||||||
signal.signal(signal.SIGWINCH, sync)
|
signal.signal(signal.SIGWINCH, sync) # type: ignore[arg-type]
|
||||||
|
|
||||||
proc = subprocess.Popen(inner)
|
proc = subprocess.Popen(inner)
|
||||||
# Initial sync is deferred — see _STARTUP_SYNC_DELAY_SEC.
|
# Initial sync is deferred — see _STARTUP_SYNC_DELAY_SEC.
|
||||||
|
|||||||
@@ -223,7 +223,6 @@ def bundle_host_port(
|
|||||||
f"no port mapping on {host_ip} for {container} "
|
f"no port mapping on {host_ip} for {container} "
|
||||||
f"{container_port}/tcp; got: {(result.stdout or '').strip()!r}"
|
f"{container_port}/tcp; got: {(result.stdout or '').strip()!r}"
|
||||||
)
|
)
|
||||||
return -1 # unreachable; die() never returns
|
|
||||||
|
|
||||||
|
|
||||||
def stop_bundle(slug: str) -> None:
|
def stop_bundle(slug: str) -> None:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ REPO_DIR = str(Path(__file__).resolve().parent.parent.parent)
|
|||||||
def read_tty_line() -> str:
|
def read_tty_line() -> str:
|
||||||
"""Mirror `IFS= read -r REPLY </dev/tty`. Falls back to stdin."""
|
"""Mirror `IFS= read -r REPLY </dev/tty`. Falls back to stdin."""
|
||||||
try:
|
try:
|
||||||
with open("/dev/tty", "r") as tty:
|
with open("/dev/tty", "r", encoding="utf-8") as tty:
|
||||||
return tty.readline().rstrip("\n")
|
return tty.readline().rstrip("\n")
|
||||||
except OSError:
|
except OSError:
|
||||||
return sys.stdin.readline().rstrip("\n")
|
return sys.stdin.readline().rstrip("\n")
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ def edit_in_editor(content: str, *, suffix: str = ".tmp") -> str | None:
|
|||||||
path = f.name
|
path = f.name
|
||||||
try:
|
try:
|
||||||
subprocess.run([editor, path], check=False)
|
subprocess.run([editor, path], check=False)
|
||||||
with open(path) as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
edited = f.read()
|
edited = f.read()
|
||||||
return edited if edited != content else None
|
return edited if edited != content else None
|
||||||
finally:
|
finally:
|
||||||
@@ -296,7 +296,7 @@ def cmd_supervise(argv: list[str]) -> int:
|
|||||||
else:
|
else:
|
||||||
error("supervise exited on a fatal error (no detail captured).")
|
error("supervise exited on a fatal error (no detail captured).")
|
||||||
return e.code if isinstance(e.code, int) else 1
|
return e.code if isinstance(e.code, int) else 1
|
||||||
except Exception as e:
|
except Exception as e: # noqa: W0718 — catch supervise crash for logging
|
||||||
log_path = _write_crash_log(e)
|
log_path = _write_crash_log(e)
|
||||||
error(f"supervise crashed: {type(e).__name__}: {e}")
|
error(f"supervise crashed: {type(e).__name__}: {e}")
|
||||||
error(f"full traceback written to {log_path}")
|
error(f"full traceback written to {log_path}")
|
||||||
@@ -439,7 +439,7 @@ def _render(
|
|||||||
selected: int,
|
selected: int,
|
||||||
status_line: str,
|
status_line: str,
|
||||||
*,
|
*,
|
||||||
green_attr: int = 0,
|
green_attr: int = 0, # noqa: F841 — unused, but required by interface
|
||||||
) -> None:
|
) -> None:
|
||||||
stdscr.erase()
|
stdscr.erase()
|
||||||
h, w = stdscr.getmaxyx()
|
h, w = stdscr.getmaxyx()
|
||||||
|
|||||||
@@ -39,12 +39,14 @@ def filter_select(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = _run_picker(items, title=title, tty_fd=tty_fd.fileno())
|
# Use os.dup() to duplicate the fd so the original file object
|
||||||
|
# and FileIO in _run_picker each manage independent copies,
|
||||||
|
# preventing double-close errors.
|
||||||
|
fd_dup = os.dup(tty_fd.fileno())
|
||||||
|
return _run_picker(items, title=title, tty_fd=fd_dup)
|
||||||
finally:
|
finally:
|
||||||
tty_fd.close()
|
tty_fd.close()
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Internal implementation
|
# Internal implementation
|
||||||
@@ -89,7 +91,7 @@ def _run_picker(items: list[str], *, title: str, tty_fd: int) -> Optional[str]:
|
|||||||
curses.nocbreak()
|
curses.nocbreak()
|
||||||
curses.echo()
|
curses.echo()
|
||||||
curses.endwin()
|
curses.endwin()
|
||||||
except Exception:
|
except Exception: # noqa: W0718 — curses can raise many error types
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
sys.__stdin__ = orig_stdin # type: ignore[assignment]
|
sys.__stdin__ = orig_stdin # type: ignore[assignment]
|
||||||
|
|||||||
@@ -117,5 +117,5 @@ def _split_owner_repo(owner_repo: str) -> tuple[str, str]:
|
|||||||
def _read_error_body(exc: urllib.error.HTTPError) -> str:
|
def _read_error_body(exc: urllib.error.HTTPError) -> str:
|
||||||
try:
|
try:
|
||||||
return exc.read().decode("utf-8", errors="replace")
|
return exc.read().decode("utf-8", errors="replace")
|
||||||
except Exception:
|
except Exception: # noqa: broad-exception-caught — safely fallback to empty error message
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -141,13 +141,15 @@ def egress_manifest_routes(
|
|||||||
routes are merged."""
|
routes are merged."""
|
||||||
out: list[EgressRoute] = []
|
out: list[EgressRoute] = []
|
||||||
for r in bottle.egress.routes:
|
for r in bottle.egress.routes:
|
||||||
|
tls_pt = r.Pipelock.Config.get("tls_passthrough", False)
|
||||||
|
tls_passthrough = tls_pt if isinstance(tls_pt, bool) else False
|
||||||
out.append(EgressRoute(
|
out.append(EgressRoute(
|
||||||
host=r.Host,
|
host=r.Host,
|
||||||
path_allowlist=r.PathAllowlist,
|
path_allowlist=r.PathAllowlist,
|
||||||
auth_scheme=r.AuthScheme,
|
auth_scheme=r.AuthScheme,
|
||||||
token_ref=r.TokenRef,
|
token_ref=r.TokenRef,
|
||||||
roles=r.Role,
|
roles=r.Role,
|
||||||
tls_passthrough=r.Pipelock.TlsPassthrough,
|
tls_passthrough=tls_passthrough,
|
||||||
))
|
))
|
||||||
return tuple(out)
|
return tuple(out)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -89,7 +89,7 @@ def _read_secret_silent(name: str, prompt_body: str) -> str:
|
|||||||
if not (sys.stdin.isatty() or sys.stderr.isatty()):
|
if not (sys.stdin.isatty() or sys.stderr.isatty()):
|
||||||
# Fall back to /dev/tty so this still works when stdin is a pipe.
|
# Fall back to /dev/tty so this still works when stdin is a pipe.
|
||||||
try:
|
try:
|
||||||
tty = open("/dev/tty", "r+")
|
tty = open("/dev/tty", "r+", encoding="utf-8")
|
||||||
except OSError:
|
except OSError:
|
||||||
die(
|
die(
|
||||||
f"cannot prompt for secret '{name}': no tty available. "
|
f"cannot prompt for secret '{name}': no tty available. "
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ class GitHttpHandler(BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
def log_message(self, format: str, *args: object) -> None: # type: ignore
|
def log_message(self, format: str, *args: object) -> None: # type: ignore # noqa: A002
|
||||||
sys.stdout.write(format % args + "\n")
|
sys.stdout.write(format % args + "\n")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ class Agent:
|
|||||||
git_raw = d.get("git-gate")
|
git_raw = d.get("git-gate")
|
||||||
if git_raw is not None:
|
if git_raw is not None:
|
||||||
gd = as_json_object(git_raw, f"agent '{name}' git-gate")
|
gd = as_json_object(git_raw, f"agent '{name}' git-gate")
|
||||||
for k in gd.keys():
|
for k in gd:
|
||||||
if k != "user":
|
if k != "user":
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"agent '{name}' git-gate.{k} is not allowed at the "
|
f"agent '{name}' git-gate.{k} is not allowed at the "
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ipaddress
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
@@ -43,17 +42,18 @@ def validate_egress_routes(
|
|||||||
class PipelockRoutePolicy:
|
class PipelockRoutePolicy:
|
||||||
"""Per-route pipelock policy overrides.
|
"""Per-route pipelock policy overrides.
|
||||||
|
|
||||||
`TlsPassthrough` adds the route host to pipelock's
|
Stores raw pipelock configuration that's passed through to the
|
||||||
`tls_interception.passthrough_domains`, so pipelock still enforces
|
pipelock sidecar. Pipelock validates all config options, so
|
||||||
the hostname allowlist but does not MITM/decrypt request bodies or
|
bot-bottle forwards manifest settings without coercion or strict
|
||||||
headers for that host.
|
validation. Supported options include:
|
||||||
|
|
||||||
`SsrfIpAllowlist` adds explicit IPs/CIDRs to pipelock's SSRF
|
- `tls_passthrough`: bool — skip TLS MITM for this host
|
||||||
allowlist for private/internal destinations behind this route.
|
- `ssrf_ip_allowlist`: list of CIDR/IP — allow private destinations
|
||||||
|
- `skip_scan_for_extensions`: list of file extensions to skip DLP
|
||||||
|
scanning for (e.g., [".whl", ".tar.gz"])
|
||||||
"""
|
"""
|
||||||
|
|
||||||
TlsPassthrough: bool = False
|
Config: dict[str, object] = field(default_factory=dict)
|
||||||
SsrfIpAllowlist: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(
|
def from_dict(
|
||||||
@@ -61,44 +61,7 @@ class PipelockRoutePolicy:
|
|||||||
) -> "PipelockRoutePolicy":
|
) -> "PipelockRoutePolicy":
|
||||||
label = f"bottle '{bottle_name}' egress.routes[{idx}] pipelock"
|
label = f"bottle '{bottle_name}' egress.routes[{idx}] pipelock"
|
||||||
d = as_json_object(raw, label)
|
d = as_json_object(raw, label)
|
||||||
for k in d:
|
return cls(Config=d)
|
||||||
if k not in ("tls_passthrough", "ssrf_ip_allowlist"):
|
|
||||||
raise ManifestError(
|
|
||||||
f"{label} has unknown key {k!r}; "
|
|
||||||
f"only 'tls_passthrough' and 'ssrf_ip_allowlist' "
|
|
||||||
f"are accepted"
|
|
||||||
)
|
|
||||||
tls_passthrough_raw = d.get("tls_passthrough", False)
|
|
||||||
if not isinstance(tls_passthrough_raw, bool):
|
|
||||||
raise ManifestError(
|
|
||||||
f"{label}.tls_passthrough must be a boolean "
|
|
||||||
f"(was {type(tls_passthrough_raw).__name__})"
|
|
||||||
)
|
|
||||||
ssrf_raw = d.get("ssrf_ip_allowlist", [])
|
|
||||||
if not isinstance(ssrf_raw, list):
|
|
||||||
raise ManifestError(
|
|
||||||
f"{label}.ssrf_ip_allowlist must be an array "
|
|
||||||
f"(was {type(ssrf_raw).__name__})"
|
|
||||||
)
|
|
||||||
ssrf_ip_allowlist: list[str] = []
|
|
||||||
for j, item in enumerate(ssrf_raw):
|
|
||||||
if not isinstance(item, str) or not item:
|
|
||||||
raise ManifestError(
|
|
||||||
f"{label}.ssrf_ip_allowlist[{j}] must be a non-empty "
|
|
||||||
f"string (was {type(item).__name__})"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
ipaddress.ip_network(item, strict=False)
|
|
||||||
except ValueError as e:
|
|
||||||
raise ManifestError(
|
|
||||||
f"{label}.ssrf_ip_allowlist[{j}] must be an IP address "
|
|
||||||
f"or CIDR (was {item!r}): {e}"
|
|
||||||
)
|
|
||||||
ssrf_ip_allowlist.append(item)
|
|
||||||
return cls(
|
|
||||||
TlsPassthrough=tls_passthrough_raw,
|
|
||||||
SsrfIpAllowlist=tuple(ssrf_ip_allowlist),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ class GitUser:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, bottle_name: str, raw: object) -> "GitUser":
|
def from_dict(cls, bottle_name: str, raw: object) -> "GitUser":
|
||||||
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate.user")
|
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate.user")
|
||||||
for k in d.keys():
|
for k in d:
|
||||||
if k not in {"name", "email"}:
|
if k not in {"name", "email"}:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' git-gate.user has unknown key {k!r}; "
|
f"bottle '{bottle_name}' git-gate.user has unknown key {k!r}; "
|
||||||
@@ -281,7 +281,7 @@ def parse_git_gate_config(
|
|||||||
raw: object,
|
raw: object,
|
||||||
) -> tuple[tuple[GitEntry, ...], GitUser]:
|
) -> tuple[tuple[GitEntry, ...], GitUser]:
|
||||||
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate")
|
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate")
|
||||||
for k in d.keys():
|
for k in d:
|
||||||
if k not in {"user", "repos"}:
|
if k not in {"user", "repos"}:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' git-gate has unknown key {k!r}; "
|
f"bottle '{bottle_name}' git-gate has unknown key {k!r}; "
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ def load_bottles_from_dir(bottles_dir: Path) -> dict[str, Bottle]:
|
|||||||
try:
|
try:
|
||||||
fm, _body = parse_frontmatter(path.read_text())
|
fm, _body = parse_frontmatter(path.read_text())
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise ManifestError(f"could not read {path}: {e}")
|
raise ManifestError(f"could not read {path}: {e}") from e
|
||||||
except YamlSubsetError as e:
|
except YamlSubsetError as e:
|
||||||
raise ManifestError(f"{path}: {e}")
|
raise ManifestError(f"{path}: {e}") from e
|
||||||
validate_bottle_frontmatter_keys(path, fm.keys())
|
validate_bottle_frontmatter_keys(path, fm.keys())
|
||||||
raws[name] = fm
|
raws[name] = fm
|
||||||
return resolve_bottles(raws)
|
return resolve_bottles(raws)
|
||||||
@@ -66,7 +66,7 @@ def load_agents_from_dir(
|
|||||||
agents_dir: Path,
|
agents_dir: Path,
|
||||||
bottle_names: set[str],
|
bottle_names: set[str],
|
||||||
*,
|
*,
|
||||||
source: str,
|
source: str, # noqa: F841 — unused, but required by interface
|
||||||
) -> dict[str, Agent]:
|
) -> dict[str, Agent]:
|
||||||
"""Walk `<agents_dir>/*.md`, parse each as an agent, and return
|
"""Walk `<agents_dir>/*.md`, parse each as an agent, and return
|
||||||
`{name: Agent}`. The Markdown body becomes the agent's prompt.
|
`{name: Agent}`. The Markdown body becomes the agent's prompt.
|
||||||
@@ -87,9 +87,9 @@ def load_agents_from_dir(
|
|||||||
try:
|
try:
|
||||||
fm, body = parse_frontmatter(path.read_text())
|
fm, body = parse_frontmatter(path.read_text())
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise ManifestError(f"could not read {path}: {e}")
|
raise ManifestError(f"could not read {path}: {e}") from e
|
||||||
except YamlSubsetError as e:
|
except YamlSubsetError as e:
|
||||||
raise ManifestError(f"{path}: {e}")
|
raise ManifestError(f"{path}: {e}") from e
|
||||||
validate_agent_frontmatter_keys(path, fm.keys())
|
validate_agent_frontmatter_keys(path, fm.keys())
|
||||||
# Build the dict Agent.from_dict expects. The body becomes
|
# Build the dict Agent.from_dict expects. The body becomes
|
||||||
# prompt; Claude Code passthrough fields stay in fm and get
|
# prompt; Claude Code passthrough fields stay in fm and get
|
||||||
|
|||||||
+14
-2
@@ -132,8 +132,11 @@ def pipelock_effective_ssrf_ip_allowlist(
|
|||||||
"""
|
"""
|
||||||
seen: dict[str, None] = {ip: None for ip in extra}
|
seen: dict[str, None] = {ip: None for ip in extra}
|
||||||
for route in bottle.egress.routes:
|
for route in bottle.egress.routes:
|
||||||
for ip in route.Pipelock.SsrfIpAllowlist:
|
ssrf_raw = route.Pipelock.Config.get("ssrf_ip_allowlist", [])
|
||||||
seen.setdefault(ip, None)
|
if isinstance(ssrf_raw, list):
|
||||||
|
for ip in ssrf_raw:
|
||||||
|
if isinstance(ip, str):
|
||||||
|
seen.setdefault(ip, None)
|
||||||
return sorted(seen.keys())
|
return sorted(seen.keys())
|
||||||
|
|
||||||
|
|
||||||
@@ -220,6 +223,15 @@ def pipelock_build_config(
|
|||||||
)
|
)
|
||||||
if effective_ssrf_ip_allowlist:
|
if effective_ssrf_ip_allowlist:
|
||||||
cfg["ssrf"] = {"ip_allowlist": effective_ssrf_ip_allowlist}
|
cfg["ssrf"] = {"ip_allowlist": effective_ssrf_ip_allowlist}
|
||||||
|
|
||||||
|
# Merge per-route pipelock config (e.g., response_body_scanning settings).
|
||||||
|
# Routes can specify arbitrary pipelock options that apply globally.
|
||||||
|
for route in bottle.egress.routes:
|
||||||
|
for key, value in route.Pipelock.Config.items():
|
||||||
|
if key not in ("tls_passthrough", "ssrf_ip_allowlist"):
|
||||||
|
if key not in cfg:
|
||||||
|
cfg[key] = value
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -519,22 +519,22 @@ def _atomic_write(path: Path, content: str, *, mode: int) -> None:
|
|||||||
try:
|
try:
|
||||||
import fcntl as _fcntl
|
import fcntl as _fcntl
|
||||||
|
|
||||||
def _try_flock(fd: int) -> None:
|
def _try_flock(fd: int) -> None: # type: ignore[reportRedeclaration]
|
||||||
try:
|
try:
|
||||||
_fcntl.flock(fd, _fcntl.LOCK_EX)
|
_fcntl.flock(fd, _fcntl.LOCK_EX)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _try_funlock(fd: int) -> None:
|
def _try_funlock(fd: int) -> None: # type: ignore[reportRedeclaration]
|
||||||
try:
|
try:
|
||||||
_fcntl.flock(fd, _fcntl.LOCK_UN)
|
_fcntl.flock(fd, _fcntl.LOCK_UN)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
except ImportError: # pragma: no cover — Windows path
|
except ImportError: # pragma: no cover — Windows path
|
||||||
def _try_flock(fd: int) -> None:
|
def _try_flock(fd: int) -> None: # noqa: F841 — Windows fallback
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _try_funlock(fd: int) -> None:
|
def _try_funlock(fd: int) -> None: # noqa: F841 — Windows fallback
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -590,7 +590,7 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
server_version = f"{SERVER_NAME}/{SERVER_VERSION}"
|
server_version = f"{SERVER_NAME}/{SERVER_VERSION}"
|
||||||
|
|
||||||
def log_message(self, format: str, *args: typing.Any) -> None:
|
def log_message(self, format: str, *args: typing.Any) -> None: # noqa: A002
|
||||||
if os.environ.get("SUPERVISE_DEBUG"):
|
if os.environ.get("SUPERVISE_DEBUG"):
|
||||||
super().log_message(format, *args)
|
super().log_message(format, *args)
|
||||||
|
|
||||||
@@ -630,7 +630,7 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
except _RpcError as e:
|
except _RpcError as e:
|
||||||
self._write_jsonrpc(jsonrpc_error(req.id, e.code, e.message))
|
self._write_jsonrpc(jsonrpc_error(req.id, e.code, e.message))
|
||||||
return
|
return
|
||||||
except Exception as e: # pragma: no cover — defensive
|
except Exception as e: # noqa: W0718 — catch-all for RPC dispatch errors
|
||||||
sys.stderr.write(f"supervise: internal error: {e}\n")
|
sys.stderr.write(f"supervise: internal error: {e}\n")
|
||||||
self._write_jsonrpc(jsonrpc_error(req.id, ERR_INTERNAL, "internal error"))
|
self._write_jsonrpc(jsonrpc_error(req.id, ERR_INTERNAL, "internal error"))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ from bot_bottle.backend.docker.network import (
|
|||||||
network_create_internal,
|
network_create_internal,
|
||||||
network_remove,
|
network_remove,
|
||||||
)
|
)
|
||||||
from bot_bottle.backend.docker.pipelock import (
|
from bot_bottle.pipelock import (
|
||||||
PIPELOCK_CA_CERT_IN_CONTAINER, # type: ignore
|
PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||||
PIPELOCK_CA_KEY_IN_CONTAINER, # type: ignore
|
PIPELOCK_CA_KEY_IN_CONTAINER,
|
||||||
pipelock_tls_init,
|
|
||||||
)
|
)
|
||||||
|
from bot_bottle.backend.docker.pipelock import pipelock_tls_init
|
||||||
from bot_bottle.pipelock import PipelockProxy
|
from bot_bottle.pipelock import PipelockProxy
|
||||||
from bot_bottle.backend.docker.pipelock_apply import (
|
from bot_bottle.backend.docker.pipelock_apply import (
|
||||||
PipelockApplyError,
|
PipelockApplyError,
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ class TestPipelockPolicy(unittest.TestCase):
|
|||||||
"host": "api.openai.com",
|
"host": "api.openai.com",
|
||||||
"pipelock": {"tls_passthrough": True},
|
"pipelock": {"tls_passthrough": True},
|
||||||
}])
|
}])
|
||||||
self.assertTrue(b.egress.routes[0].Pipelock.TlsPassthrough)
|
self.assertTrue(b.egress.routes[0].Pipelock.Config["tls_passthrough"])
|
||||||
|
|
||||||
def test_ssrf_ip_allowlist_route_policy(self):
|
def test_ssrf_ip_allowlist_route_policy(self):
|
||||||
b = _bottle([{
|
b = _bottle([{
|
||||||
@@ -233,44 +233,28 @@ class TestPipelockPolicy(unittest.TestCase):
|
|||||||
"pipelock": {"ssrf_ip_allowlist": ["100.78.141.42/32"]},
|
"pipelock": {"ssrf_ip_allowlist": ["100.78.141.42/32"]},
|
||||||
}])
|
}])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
("100.78.141.42/32",),
|
["100.78.141.42/32"],
|
||||||
b.egress.routes[0].Pipelock.SsrfIpAllowlist,
|
b.egress.routes[0].Pipelock.Config["ssrf_ip_allowlist"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_tls_passthrough_defaults_false(self):
|
def test_skip_scan_for_extensions_route_policy(self):
|
||||||
|
b = _bottle([{
|
||||||
|
"host": "files.pythonhosted.org",
|
||||||
|
"pipelock": {"skip_scan_for_extensions": [".whl", ".tar.gz"]},
|
||||||
|
}])
|
||||||
|
self.assertEqual(
|
||||||
|
[".whl", ".tar.gz"],
|
||||||
|
b.egress.routes[0].Pipelock.Config["skip_scan_for_extensions"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_config_when_pipelock_omitted(self):
|
||||||
b = _bottle([{"host": "api.openai.com"}])
|
b = _bottle([{"host": "api.openai.com"}])
|
||||||
self.assertFalse(b.egress.routes[0].Pipelock.TlsPassthrough)
|
self.assertEqual({}, b.egress.routes[0].Pipelock.Config)
|
||||||
self.assertEqual((), b.egress.routes[0].Pipelock.SsrfIpAllowlist)
|
|
||||||
|
|
||||||
def test_pipelock_policy_must_be_object(self):
|
def test_pipelock_policy_must_be_object(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
_bottle([{"host": "x.example", "pipelock": True}])
|
_bottle([{"host": "x.example", "pipelock": True}])
|
||||||
|
|
||||||
def test_tls_passthrough_must_be_bool(self):
|
|
||||||
with self.assertRaises(ManifestError):
|
|
||||||
_bottle([{
|
|
||||||
"host": "x.example",
|
|
||||||
"pipelock": {"tls_passthrough": "yes"},
|
|
||||||
}])
|
|
||||||
|
|
||||||
def test_ssrf_ip_allowlist_must_be_array(self):
|
|
||||||
with self.assertRaises(ManifestError):
|
|
||||||
_bottle([{
|
|
||||||
"host": "x.example",
|
|
||||||
"pipelock": {"ssrf_ip_allowlist": "100.78.141.42/32"},
|
|
||||||
}])
|
|
||||||
|
|
||||||
def test_ssrf_ip_allowlist_items_must_be_cidr_or_ip(self):
|
|
||||||
with self.assertRaises(ManifestError):
|
|
||||||
_bottle([{
|
|
||||||
"host": "x.example",
|
|
||||||
"pipelock": {"ssrf_ip_allowlist": ["not-an-ip"]},
|
|
||||||
}])
|
|
||||||
|
|
||||||
def test_unknown_pipelock_key_rejected(self):
|
|
||||||
with self.assertRaises(ManifestError):
|
|
||||||
_bottle([{"host": "x.example", "pipelock": {"wat": True}}])
|
|
||||||
|
|
||||||
|
|
||||||
class TestRouteValidation(unittest.TestCase):
|
class TestRouteValidation(unittest.TestCase):
|
||||||
def test_duplicate_hosts_rejected(self):
|
def test_duplicate_hosts_rejected(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user