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>
The "Manifest" section now describes the per-file MD layout under
~/.claude-bottle/{bottles,agents}/, the filename-as-key convention,
the YAML subset constraints, and the trust boundary (bottles are
home-only by filesystem layout). Includes a working bottle example
with comments inside the frontmatter and a working agent example
showing the Markdown body as the system prompt.
Drops claude-bottle.example.json. The new examples/ tree —
examples/bottles/dev.md, examples/agents/implementer.md,
examples/agents/researcher.md — verifies the parser end-to-end via
Manifest.from_md_dirs(examples/, None).
The previous diagram showed three parallel egress lanes — agent ↔
pipelock, agent ↔ git-gate, agent ↔ cred-proxy — each going off-box
independently. That was true of an earlier shape but is now wrong on
two counts:
1. cred-proxy's outbound HTTPS routes through pipelock (set when
the SSRF / CA-trust wiring landed). All cred-proxy upstream
bytes pass pipelock's allowlist + body scanner.
2. git-gate's SSH push/fetch is direct out the egress network and
has never gone through pipelock — pipelock is HTTP-only.
Reflect both: the diagram now collapses to one HTTP/HTTPS chokepoint
(pipelock) that the agent and cred-proxy share, plus a separate SSH
lane for git-gate. Prose paragraph above the diagram updated to call
out the "everything except SSH" framing explicitly.
Verified against the current code: HTTPS_PROXY=pipelock set on the
agent in launch.py and on cred-proxy in DockerCredProxy.start;
git-gate's create-args carry no proxy env vars.
Removes the legacy `CLAUDE_BOTTLE_OAUTH_TOKEN` -> `CLAUDE_CODE_OAUTH_TOKEN`
forward in prepare.py. Bottles that need claude-code to authenticate
must declare a cred_proxy route with role: "anthropic-base-url" — there
is no fallback that hands the token to the agent directly.
Drops the now-dead BottleSpec.forward_oauth_token field, the CLI
setter that read CLAUDE_BOTTLE_OAUTH_TOKEN from the host env at
prepare time, and the forward_oauth_token=False arg in the six
pipelock integration tests.
PRD 0010 and README updated; the dev ~/claude-bottle.json gains an
anthropic-base-url route so the implementer/researcher agents keep
working.
BREAKING: bottles previously relying on the implicit OAuth forward
will now produce an agent environ without any Anthropic credential.
Verified with --dry-run: a bottle with no anthropic-base-url route
yields env_names: [] (no token at all); a bottle that declares the
route yields ANTHROPIC_BASE_URL plus a non-secret placeholder for
CLAUDE_CODE_OAUTH_TOKEN.
Replace bottle.tokens (with Kind enum and hardcoded per-kind
route/auth tables) with bottle.cred_proxy.routes — each route
declares its own path, upstream, auth_scheme, token_ref, and
optional role[]. The manifest is now the source of truth for the
proxy's runtime route table; adding an upstream is a manifest edit,
not a code change.
Agent-side rewrites move from per-kind dispatch to per-role tags
on routes:
anthropic-base-url -> set ANTHROPIC_BASE_URL=<proxy><path>
npm-registry -> write ~/.npmrc registry=
git-insteadof -> write ~/.gitconfig [url] insteadOf, keyed
off route.upstream (suppressed when
bottle.git brokers the same host)
tea-login -> add a ~/.config/tea/config.yml login
Roles are a list (string accepted as sugar). A gitea route
typically carries ["git-insteadof", "tea-login"]. Singleton roles
(anthropic-base-url, npm-registry) appear on at most one route.
token_env slots are assigned per distinct TokenRef in declaration
order — two routes sharing a token_ref (e.g. github API + git
endpoints) share a slot.
Drops: TOKEN_KINDS, _KIND_ROUTES, _KIND_AUTH_SCHEME, _TOKEN_DEFAULT_HOST,
cred_proxy_route_path_for_gitea, the kind field on CredProxyUpstream,
and the kind-based hardcoding in pipelock_token_hosts (now derives
from route.UpstreamHost).
Legacy bottle.tokens manifests now die with a hint pointing at
bottle.cred_proxy.routes + this PRD. Tests rewritten end-to-end.
Docs + example.json + the dev ~/claude-bottle.json updated to match.