The update-badges workflow only refreshed pylint and pyright. Add a
coverage step that runs the unit suite under coverage.py, extracts the
TOTAL percentage, and updates a new coverage badge in the README.
Also trigger the workflow on .coveragerc changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
Provider routes (the agent talking to its own LLM API — api.anthropic.com,
the Codex backend, etc.) carry the whole conversation payload, which is the
worst source of token-shaped false positives. egress_routes_for_bottle now
fills outbound_on_match=redact on any provider route that doesn't set it
explicitly, so a match there is scrubbed and forwarded rather than blocked
or queued for the operator. A provider that sets the policy keeps its
choice; manifest routes still default to supervise.
Tests: provider route gets redact default, explicit provider policy
preserved, manifest route unaffected. README + PRD 0062 updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnvBjPZC5V7qeQpFbQdDmS
Give each egress route a policy for what the proxy does when an outbound
DLP detector matches a token, defaulting to the supervise flow added in
the previous commit. The goal is cutting false-positive friction without
weakening default-deny.
- redact: scrub the matched value(s) from the body, non-host headers, and
path/query via redact_tokens, then re-scan. Forward if clean; fail
closed with a 403 if a match remains on a surface redaction can't
rewrite (the hostname, or a unicode-evasion token). For routes where a
token-shaped value is noise the upstream doesn't need.
- block: the original hard 403, never overridable.
- supervise (default, unset): hold the request for operator approval.
Structural blocks (CRLF, no safelist-able value) stay hard 403s under
every policy.
Threads outbound_on_match from the bottle manifest (manifest_egress)
through the resolved EgressRoute and rendered routes.yaml (egress.py) to
the addon's Route (egress_addon_core), and round-trips it via the
list-egress-routes introspection endpoint. The allow/egress-block tool
descriptions document the new key.
Tests: manifest parse/validation, core parse/validation, full
manifest->render->addon round-trip for redact. README + PRD 0062 updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnvBjPZC5V7qeQpFbQdDmS
When the outbound DLP catches a token, route the block through the
existing supervisor approval queue instead of returning 403 outright.
The egress proxy holds the request open until the operator answers, then
remembers an approved value for the life of the proxy so the request --
and later ones carrying it -- flow through. Fails closed on rejection,
timeout, malformed response, or when supervise is disabled.
- ScanResult.matched carries the raw matched substring (sidecar-only;
never logged or written to the proposal). scan_outbound and the token
detectors take a safe_tokens set and skip approved values, continuing
past a safelisted match so a second secret in the same request is
still caught.
- New egress-token-allow proposal tool, written directly to the queue by
the addon (the gitleaks-allow pattern from PRD 0061). build_token_allow
_payload renders host/method/path/detector reason + redacted context.
- Async request hook polls the queue without stalling the proxy event
loop; EGRESS_TOKEN_ALLOW_TIMEOUT_SECONDS (default 300) bounds the wait.
- Supervisor TUI renders egress-token-allow like gitleaks-allow: report
only, modify unavailable, approval requires a recorded reason.
- Unit tests for the matched/safe-tokens plumbing, payload builder, tool
constant round-trip, and TUI paths; README + PRD 0062.
Closes#261.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnvBjPZC5V7qeQpFbQdDmS
The egress route fields table described `role` as a functional field
that wires built-in auth flows. PRD 0029 removed the
`claude_code_oauth` role; the manifest parser now rejects any `role`
value as reserved-for-future-use. Provider auth routes are injected
from `agent_provider.auth_token`.
- README: fix the `role` row to state it is reserved and any value is
rejected at load.
- examples/bottles/claude.md: the manual `api.anthropic.com` route used
the rejected `role` key and, even without it, would be silently
dropped (provider-injected routes win for a provisioned host) — so its
auth never took effect and the dlp comments described a route that
never exists in the plan. Replace it with the canonical
`agent_provider.auth_token` shape.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YcU7nerbg8cVj9R4EkpfLJ
Both fields were missing from the reference table added in the preceding
commit — `role` is visible in examples/bottles/claude.md and `git.fetch`
is documented in PRD 0052 but neither appeared in the README table.
Pipelock was removed in PR #193. Update the five remaining places
where current documentation (README, examples/bottles/claude.md,
tests/README.md, docs/ci.md, sidecar_bundle.py comment) still
described the old pipelock + cred-proxy topology.
Added badges to visually communicate code quality:
- pylint: 9.92/10 (0 reportable issues)
- pyright: 0 errors (100% type safe)
These badges clearly indicate the project's code quality standards
and type safety achievements to users and contributors.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Debugging a live codex smolmachines bottle surfaced three independent
failures past the sign-in screen; fix each so forward_host_credentials
works end to end:
- codex_auth: dummy access/id tokens now inherit the *real* host token's
exp instead of now+1h. Codex (0.135) refreshes when its local token's
JWT exp lapses; with a placeholder refresh_token that refresh fails and
drops to the sign-in screen. Aligning exp tracks the real token's life.
- prepare: set CODEX_CA_CERTIFICATE to the agent CA bundle for codex
bottles. Codex is rustls and ignores the system store / NODE_EXTRA_CA_
CERTS; it reads CODEX_CA_CERTIFICATE (fallback SSL_CERT_FILE) for custom
roots across HTTPS + wss, so it must be pointed at the egress MITM CA or
injection can't work without tls_passthrough.
- pipelock: auto tls_passthrough the Codex API hosts when
forward_host_credentials is on. Egress injects the bearer before
pipelock, whose header DLP then flags the JWT ("request header contains
secret") and the retry storm trips its 429. passthrough host-gates the
CONNECT but skips decrypt+rescan of egress-owned auth. The auto-added
routes aren't in bottle.egress.routes, so the hosts are added explicitly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
README manifest section documents the agent git.user overlay, the
bottle-only git.remotes boundary, and the claimed-not-vouched trust
note. Collapses the example: implementer carries its own identity
against the shared dev bottle instead of an identity-only bottle.
Refs #94
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a `git_user:` block to the example bottle frontmatter with a
one-paragraph note on what it does + that either field can be
set independently. Other doc surfaces (manifest module docstring,
provisioner module docstrings) were updated alongside the
implementation commits.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Earlier commit framed this PR as "infrastructure landed, TSI
enforcement blocked on upstream smolvm 0.8.0." Found a clean
workaround that lets us enforce now.
Smolvm persists each machine's config (including
`allowed_cidrs`) as a JSON BLOB in
`~/Library/Application Support/smolvm/server/smolvm.db`,
`vms.data`. `machine create --allow-cidr X/32` silently writes
`allowed_cidrs: null` to that row when combined with `--from`,
but smolvm reads the row at `machine start` — so patching the
row between create and start sets the allowlist for real.
New `loopback_alias.force_allowlist(machine_name, cidrs)` opens
the SQLite DB, JSON-decodes the row, sets `allowed_cidrs`, and
writes back as BLOB (Text type silently corrupts smolvm's
later reads). launch.py calls it immediately after
`machine_create` and before `machine_start`.
Verified end-to-end on macOS / Docker Desktop:
VM allowlist after start: ["127.0.0.16/32"]
VM → 127.0.0.1:3000 → BLOCKED (Permission denied)
VM → 8.8.8.8:53 → BLOCKED (Permission denied)
VM → 127.0.0.16:<bundle> → CONNECTED
The DB-patch hack is correct only because smolvm reads
`allowed_cidrs` from the row at start time (not derived in-
process). When upstream honors `--allow-cidr` with `--from`,
the call becomes redundant — drop the call and the workaround
is gone.
Tests: 4 new for `force_allowlist` (BLOB round-trip; Linux
no-op; missing DB; missing row). Total 593 unit tests pass.
README + PRD updated to reflect the fix landed (no longer
"infrastructure pending upstream"). gitea#75 can close.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PR #76 originally claimed the per-bottle alias scoping closed
gitea#75 ("agent can reach host loopback"). Verified
empirically that's not actually true: `smolvm 0.8.0 machine
create --from <smolmachine> --net --allow-cidr X/32` silently
drops the allowlist (`agent.config.json` shows `allowed_cidrs:
null`, and the running VM reaches all of `127.0.0.0/8`
regardless).
So the alias-allocation + alias-bind infrastructure is correct
pre-work, but the actual TSI enforcement is blocked on an
upstream smolvm bug. README + PRD 0023 + the module docstring
get reworded to say so plainly. gitea#75 stays open.
Workarounds tried (all dead-ends):
- `machine update --allow-cidr` doesn't exist
- stop-edit-`agent.config.json`-restart fails (smolvm removes
the file on stop)
- `--smolfile` is mutually exclusive with `--from`
- `--image localhost:<port>/...` fails because smolvm's agent
process can't reach host loopback during pull
When upstream lands a fix, our existing code (alias allocation,
port-bind, --allow-cidr in launch) will scope correctly without
further changes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PR #74's Docker-Desktop fix routed the agent through
`127.0.0.1:<random>` loopback forwards, but TSI filters by IP
only — so the allowlist `127.0.0.1/32` let the agent VM reach
**any** host service on macOS loopback (postgres, dev servers,
other bottles' published ports, mDNSResponder, ...). Real
downgrade vs the docker backend's `--internal` network.
Resolution: per-bottle loopback alias.
- New `loopback_alias` module manages a pool of
`127.0.0.16` .. `127.0.0.31` on `lo0`. macOS only routes
`127.0.0.1` by default; the extras need `sudo ifconfig lo0
alias`. `ensure_pool()` lazily adds the missing entries via
one sudo prompt on first launch per reboot — aliases persist
on `lo0` until reboot, so subsequent launches skip the
prompt entirely.
- `allocate(slug)` picks the lowest-numbered unused alias by
inspecting running bundle containers' port-binding HostIps.
No on-disk reservation — docker is the source of truth.
- Bundle bringup binds published ports to the allocated alias
(`docker run -p <alias>::<port>`) instead of `127.0.0.1`.
- TSI allowlist becomes the alias's /32 — narrows reachability
to this bottle's bundle only.
- Linux native daemons share the host's network namespace;
`127.0.0.0/8` works without aliases, so the module no-ops on
non-Darwin and returns `127.0.0.1` from `allocate`.
Tracking issue closed: gitea/issues/75.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Final PRD 0023 chunk. The PRD 0022 attack suite was already
backend-agnostic — it goes through get_bottle_backend(), so the
right dispatch happens based on CLAUDE_BOTTLE_BACKEND. Two
cleanups to make it actually run cleanly under
CLAUDE_BOTTLE_BACKEND=smolmachines:
- setUpClass raises unittest.SkipTest with a useful message when
CLAUDE_BOTTLE_BACKEND=smolmachines but smolvm isn't on PATH, or
when the host isn't macOS (libkrun + TSI single-IP allowlist is
macOS-only in v1). Without this, the test would die deep inside
backend.prepare's smolmachines_preflight rather than skipping.
- test_5_readme_push_blocked switches from a hardcoded
`git://git-gate/...` remote URL (only resolvable on docker via
the bundle's short alias) to the bottle's declared upstream URL
(`ssh://git@unreachable.invalid:22/throwaway.git`). The agent's
~/.gitconfig insteadOf rewrite — set up by provision_git on both
backends — transparently redirects to the gate, so the same test
exercises docker's `git://git-gate/...` and smolmachines's
`git://<bundle_ip>:9418/...` URLs without branching on backend.
README gets a "Backend selection" subsection under Quickstart
documenting CLAUDE_BOTTLE_BACKEND, the macOS-only v1 scope for
smolmachines, and the `curl -sSL .../install.sh | sh` install
prerequisite — per PRD 0023's acceptance criteria.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PR #74's Docker-Desktop pivot widened the smolmachines TSI
allowlist from `<bundle-ip>/32` to `127.0.0.1/32` (TSI can't
filter by port, and docker bridge IPs aren't reachable from
macOS networking). The agent VM can therefore reach any service
on macOS's loopback while the bottle is running — not just the
bundle's published ports.
README gets a "Smolmachines backend" subsection under Quickstart
spelling this out as a known v1 limitation. PRD 0023 grows a new
open question #8 with the proposed v2 fix (per-bottle loopback
alias + TSI allowlist scoped to that /32, via sudo
`ifconfig lo0 alias`).
Tracking issue: gitea.dideric.is/didericis/claude-bottle/issues/75.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>