All seven open questions now have decisions baked in:
- Q1 (HTTP-exfil scope): authoritative. Every shape MUST
block; chunk 3 expands into remediation sub-PRDs if
any of path/query/header leak today.
- Q3 (fake secret): multiple shapes, parameterized.
Three env vars (TEST_SECRET_ANTHROPIC, _AWS, _GENERIC);
test 5 loops via subTest. Resilient to gitleaks rule
renames.
- Q6 (missing backend): die. `get_bottle_backend()`'s
current behavior surfaces clearly; surprise-skips are
worse than loud failures for new-backend branches.
- Q7 (tool deps): preflight check. setUpClass runs
`which curl && which git && which dig`; SkipTest with
the missing list catches future backends shipping
thinner base images.
Updated implementation chunks + test-5 sketch to match.
No remaining open questions.
User feedback:
- Q2 (direct DNS resolver test): yes — test 4 grows a
second sub-assertion verifying `dig @8.8.8.8` from the
agent has no path out, alongside the existing
crafted-subdomain check.
- Q4 (gitleaks ordering): test 5 grows an ordering check
— asserts the rejection mentions `gitleaks` AND does
NOT mention upstream-network-phase phrases (resolve /
refused / unreachable / upstream). Confirms gitleaks
rejects BEFORE git-gate tries any upstream push.
- Q5 (CI): try it, accept fallback. New chunk 6 adds a
Gitea Actions job marked `continue-on-error: true` —
runs the suite if the runner can host compose, doesn't
block the workflow if docker-in-docker prevents it.
Three open questions remain (1: pipelock's actual DLP
coverage for non-body shapes; 3: realistic fake secret
shape vs. gitleaks regex; 6+7: backend-agnostic invocation
+ required tools — for the smolmachines work).
Draft a PRD for a composite integration test that brings up
a real bottle with a known allowlist + planted secret and
runs five attacks from inside the agent container:
1. Request to non-allowlisted hostname
2. Request to non-allowlisted IP (incl. host-header spoof)
3. Secret exfil via HTTP — path / query / body / headers
4. Secret exfil via crafted DNS subdomain
5. Secret exfil via README link pushed through git-gate
Each attack passes only when blocked with a permissions
error. The suite is backend-agnostic — runs against
whatever CLAUDE_BOTTLE_BACKEND selects — so it becomes the
gate the upcoming smolmachines spike has to pass before that
backend can substitute for Docker.
Sized into 5 chunks (fixture → attacks 1+2 → attack 3 →
attack 4 → attack 5). Seven open questions called out,
biggest being: today's pipelock probably leaks via header /
path / query because DLP only scans bodies — the test will
expose this as a real gap (chunk 3 lands with
`expectedFailure` markers if so).
PR #48 closed; treat the implementation as starting from
main, where no tmux integration exists yet. The PRD now
describes the full design (including the `_in_tmux` detection
+ helper scaffolding) as fresh work. Sized into 4 chunks:
`claude_docker_argv` refactor → tmux helpers + pane state +
`_attach_to_bottle` dispatch → new-agent flow → stop +
indicator.
Same design as before — opt-in by `\$TMUX`, split-window-then-
respawn, falls back to handoff on tmux failure or missing
binary. No external references to PR #48.
Draft a PRD that tightens PR #48's tmux integration from
"one new window per attach" to "one persistent right pane that
the dashboard's selection drives." Inside tmux (`\$TMUX` set):
dashboard in the left pane; pressing Enter or `n` spawns
claude in the right pane via `tmux split-window` on first
attach, then `tmux respawn-pane` on subsequent attaches so the
operator-focused agent is always the visible one.
Outside tmux: falls back to today's handoff. Opt-in by
environment; no flag.
Sized into 4 chunks (pane state + create → respawn → stop
integration → supersede PR #48's new-window). Seven open
questions called out, the biggest being whether the dashboard
should auto-exec into a fresh tmux session when launched
outside one (v1 says no — operators start tmux themselves).
Draft a PRD that turns the dashboard into the operator's single
surface — collapses today's two-terminal workflow (one for
`./cli.py start`, one for `./cli.py dashboard`) into a single
dashboard invocation that can spin up new agents, re-attach to
ones it already spun up, and explicitly stop them.
Picks the "handoff" mechanism from `docs/research/claude-code-
pane-in-dashboard.md` (curses.endwin → docker exec -it claude
→ stdscr.refresh) and crucially decouples the bottle's lifetime
from any single claude session: exit claude → back to dashboard
with the bottle still running; quit dashboard → tear down every
bottle the dashboard owns.
Sized into 5 chunks (refactor → picker + new-agent → re-attach
→ explicit stop → quit-cleanup). Seven open questions called
out, the biggest being modal-vs-drop-and-resume for the
preflight Y/N inside curses.
When no agent is selected, `e` / `p` do nothing (status line
shows "no agent selected") rather than falling back to today's
global discover-and-prompt. The discover-and-prompt scaffolding
in `_operator_edit_routes_flow` / `_operator_edit_allowlist_flow`
comes out entirely — selection in the agents pane is now the
only way to scope an edit. Old open-question #4 (single-bottle
shortcut behavior in proposals-pane mode) is moot and removed.
Draft a PRD that adds an "active agents" pane to the dashboard
TUI (below the existing proposals pane) and reshapes the operator
`routes edit` (e) / `pipelock edit` (p) verbs to be agent-scoped
when the cursor is in the agents pane — no more global discover
+ disambiguation prompt on every press. Tab toggles which pane
nav keys move through.
Sized into 4 chunks (discovery helper → render pane → selection
state → agent-scoped verbs). Six open questions called out, the
biggest being whether per-bottle `compose ps` on every 1s tick
scales for hosts with many bottles (answer leans toward one
label-filtered `docker ps`).
Draft a PRD that replaces the chain of per-sidecar docker SDK calls
in `claude-bottle start` with a single `docker compose` project per
instance. Each `state/<slug>/` dir gets a self-describing set of
artifacts: metadata.json, docker-compose.yml, compose.log, and the
existing transcript/ + live-config/.
Finishes PRD 0017. The `cred-proxy-block` MCP tool is renamed and
its remediation apply path is repointed at egress-proxy.
- `claude_bottle/supervise.py` — `TOOL_CRED_PROXY_BLOCK` →
`TOOL_EGRESS_PROXY_BLOCK`; `COMPONENT_FOR_TOOL` maps the new
tool ID to `egress-proxy` for audit-log routing.
- `claude_bottle/supervise_server.py` — tool definition renamed
+ description rewritten: "Call when egress-proxy refused your
HTTPS request ... Read the current routes.yaml from /etc/
claude-bottle/current-config/routes.yaml, compose a modified
version, pass the full new file plus a justification." The
syntactic validator dispatches on the new tool ID.
- `claude_bottle/backend/docker/egress_proxy_apply.py` — renamed
from `cred_proxy_apply.py`. Reads routes.yaml from
/etc/egress-proxy/routes.yaml via `docker exec cat`; validates
via `egress_proxy_addon_core.load_routes` (so both sides use
the same parser); writes via `docker cp`; SIGHUPs egress-proxy
with `docker kill --signal HUP`. `EgressProxyApplyError`
replaces `CredProxyApplyError`.
- `claude_bottle/cli/dashboard.py` — wires the new apply +
`discover_egress_proxy_slugs` helper; the operator-initiated
`routes edit <bottle>` verb now writes to egress-proxy with
`.yaml` suffix. Stale follow-up comment about path-aware
filtering removed — PRD 0017 settled that question.
- `tests/integration/test_supervise_sidecar.py` — restores the
approval round-trip test (chunk 2 had switched it to a reject
path because no cred-proxy existed). Approval stubs
`apply_routes_change` so the test focuses on the supervise
queue/response plumbing rather than docker-exec into a real
egress-proxy sidecar (that's covered separately).
- `tests/unit/test_egress_proxy_apply.py` — rewritten against
the new validator; covers JSON shape, missing routes key,
partial-auth-pair rejection (the addon-core parser catches
these before SIGHUP).
- PRDs 0010 + 0014 — status headers updated to
Superseded / Retargeted with a callout block pointing at PRD
0017's migration section. Historical text preserved.
384 unit + integration tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Earlier draft had `auth_scheme: "none"` as the unauthenticated
signal — awkward sentinel. Nest the two credential-injection
fields under an optional `auth` key instead. Presence of the key
= authenticated; absence = unauthenticated. Empty `auth: {}` is
an error (omission is what means "no auth").
Touches: scope bullet, manifest example, mitmproxy addon
description's auth-handling step. Two trailing `auth_scheme:
"none"` references kept as historical context for what the new
shape replaces.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Significant rewrite of PRD 0017 based on PR #25 design discussion.
Original draft proposed adding `path_allowlist` to the existing
cred-proxy. That bought opt-in path filtering for tools that
voluntarily routed through cred-proxy (Claude Code, git, npm) —
but raw `curl https://github.com/foo` from the agent goes to
HTTPS_PROXY=pipelock and bypasses cred-proxy entirely, so any
universal enforcement claim was a lie.
New design: replace cred-proxy with a mitmproxy-based egress-proxy
that becomes the agent's HTTP_PROXY/HTTPS_PROXY. Every agent
HTTP/HTTPS request flows through it before reaching pipelock.
Path-level allow/deny enforcement is universal because the proxy
is on every leg. The proxy also absorbs cred-proxy's credential
injection role (mitmproxy addon hooks request → strip + inject
Authorization).
Net sidecar count: unchanged. cred-proxy is replaced 1:1 by
egress-proxy. Pipelock stays as hostname allow + DLP downstream
of egress-proxy.
Decisions baked in per PR-#25 discussion:
- Tool: mitmproxy (designed for this; Python addons; well-maintained).
- CA custody: egress-proxy holds the per-bottle MITM CA key
(concentration accepted; documented in trust-domain section).
- Migration: hard cutover. Existing `bottle.cred_proxy.routes[]`
manifests fail-fast at load time with a pointer at this PRD.
Open questions retained for the implementation PRs: addon
distribution (bake vs mount), prefix-vs-glob match, double-strip
of Authorization between egress-proxy and pipelock, whether
pipelock keeps TLS interception or stays hostname-only post-cutover,
performance under two-MITM-hops.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extends cred-proxy to filter (not just route) paths, including for
unauthenticated upstreams via a new `auth_scheme: "none"` mode and
`path_allowlist` field per route. Pipelock keeps its hostname
allowlist + DLP role; cred-proxy adds path-level enforcement for
routes that opt in.
Motivated by PR #25's follow-up note in _apply_pipelock_url: pipelock
2.3.0's api_allowlist is hostname-only, so approving pipelock-block
opens the entire host. For shared platforms (github.com, gitlab.com,
public registries) operators usually want narrower-than-host
granularity.
Draft status; open questions on match semantics, allow-route-with-
empty-allowlist edge case, and the eventual MCP tool shape for
agent-proposed path additions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The project started life as bash scripts and got rewritten to Python
(documented in docs/research/bash-vs-python-vs-go.md). Several docs
still carried the old "bash-first" framing — misleading for anyone
reading them now (8.7k lines of Python vs. ~130 lines of bash, all
in scripts/demo*.sh).
- CLAUDE.md "What this is" + "Conventions": orchestrator is Python,
posture is stdlib-first.
- docs/prds/0010-cred-proxy.md, docs/research/manifest-format-and-
grouping.md: quoted CLAUDE.md's old wording — re-quote.
- docs/research/built-in-supervisor-design.md, landscape-containerized-
claude.md, agent-sandbox-landscape.md, pipelock-assessment.md,
network-egress-guard.md: drop "bash-first" claims about the project,
keep accurate descriptions of external tools' bash usage.
Leaves untouched: bash code-fence syntax in examples, README's
literal `bash scripts/demo.sh` invocation (the demo IS bash),
Claude Code's "Bash tool" references, IVIJL/devbox bash description
(that project actually is bash), and the bash-vs-python-vs-go
research note that records the rewrite decision.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds PRD 0016, the heaviest of the three remediation engines in the
stuck-agent recovery flow (overview in PRD 0012, foundation in PRD
0013). Wires the capability block path: rebuild orchestrator,
state-preservation helper, capability-block end-to-end. On approval
the orchestrator tears down the bottle, builds from the new
Dockerfile, and starts a replacement on the same branch via
state-preservation.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds PRD 0015, the second remediation engine in the stuck-agent
recovery flow (overview in PRD 0012, foundation in PRD 0013). Wires
the pipelock block path with restart-based reload: supervisor writes
the new allowlist on approval and restarts pipelock, proactive
pipelock edit TUI verb, pipelock audit log filled in. SIGHUP reload
for pipelock is deferred to a follow-up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds PRD 0014, the first end-to-end remediation engine in the
stuck-agent recovery flow (overview in PRD 0012, foundation in PRD
0013). Wires the cred-proxy block path: SIGHUP-based hot reload of
routes.json on cred-proxy, supervisor write-on-approval, proactive
routes edit TUI verb, cred-proxy audit log filled in.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds PRD 0013, the shared foundation for the stuck-agent recovery flow
(overview in PRD 0012). Defines the MCP sidecar, the three tool
definitions, the proposal queue, the read-only current-config mount,
the minimal TUI, and the audit log format. Approval handlers are
deliberately no-ops; the actual remediations land in PRDs 0014, 0015,
and 0016.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures the rationale for placing the MCP server outside the agent
container. The bottle wall doesn't strictly require it (the operator
TUI is the actual gate), but pattern consistency, audit metadata
trust, connection lifecycle, future enforcement headroom, and
pipelock cleanliness all argue for sidecar placement.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the text-only /supervise/notify protocol with three MCP tools
the agent calls directly: cred-proxy-block, pipelock-block, and
capability-block. Each tool carries the agent's proposed config file
(routes.json, pipelock allowlist, or Dockerfile) plus a justification.
Adds a new MCP sidecar, a read-only current-config mount in the agent
container, and renames "capability gap" to "capability block" to match
the tool name. The text-only-vs-structured tradeoff is captured as an
Open question with pros/cons on both sides.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Introduces cred-proxy block, pipelock block, and capability gap as the
three named categories of stuck. Adds pipelock-edit support (restart-
based for v1) parallel to the existing cred-proxy routes-edit path,
plus a pipelock audit log. Broadens Goals to cover all three paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rewrites Scope, Proposed Design, Data model, and Open questions to
match the model where /supervise/notify is text-in/text-out, routes
edits + SIGHUP reload are supervisor-side tooling, and manifest
rebuilds are the heavy path. Adds the per-bottle routes-edit audit log.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
claude-bottle has a single primary user today; an automated
JSON → MD migration tool is overkill. Hand-rewriting one file
is the migration cost. The resolver still dies with a pointer
at the README's manifest section if a stale claude-bottle.json
is found alongside no .claude-bottle/ directory, so the breaking
change isn't silent.
Drops: SC #6 (migration tool), the "Migration command" In Scope
sub-bullet, the migrate_manifest.py / cli wiring entries from
Existing code touched, the tests/integration/test_migrate_manifest.py
entry from Tests, the destructive-vs-additive open question.
Renumbers the remaining success criteria 6, 7 (formerly 7, 8).
Backward-compat section rewritten around hand-rewrite.
Specs the implementation chosen in the PR #16 closing comment:
per-file MD-with-YAML-frontmatter layout for both bottles and
agents, with a hand-rolled YAML subset parser (no PyYAML).
Layout:
- $HOME/.claude-bottle/bottles/<name>.md (home-only)
- $HOME/.claude-bottle/agents/<name>.md (home agents)
- $CWD/.claude-bottle/agents/<name>.md (repo-supplied agents)
The trust boundary that PRD-0011-v1 (closed PR #15) tried to
enforce in the resolver now falls out of filesystem layout —
$CWD/.claude-bottle/ has no bottles/ subdir, the loader doesn't
look there. Filesystem layout IS the enforcement.
Eight success criteria, including: stdlib-only (no new runtime
dep), idempotent migration command, agent files shaped close to
Claude Code's existing subagent spec so the same file can drop
into ~/.claude/agents/.
PRD-only; no implementation in this commit. PRD slot 0011 is
intentionally reused — the v1 file was never merged to main.
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.
Three coupled fixes that close a documented bypass of git-gate's
gitleaks pre-receive hook:
1. cred-proxy refuses git smart-HTTP push at runtime. Any path
ending in /git-receive-pack or /info/refs?service=git-receive-pack
returns 403 with a pointer at the bottle.git SSH path. Fetch
(upload-pack) is still allowed — the bypass we're closing is
push, where gitleaks is the load-bearing scanner. Hard guarantee.
2. The provisioner suppresses the cred-proxy `~/.gitconfig` insteadOf
rewrite for any host already declared in bottle.git. git-gate is
the canonical git path there; we don't write a competing rule
that would let `git clone https://<host>/...` succeed in ways
that confuse on push. Defense in depth — (1) is the hard guarantee.
3. cred-proxy routes its outbound HTTPS through pipelock. The
sidecar's environ now sets HTTPS_PROXY=<pipelock-url>, and the
image's entrypoint runs `update-ca-certificates` over the
per-bottle pipelock CA (docker cp'd into
/usr/local/share/ca-certificates/pipelock.crt before start) so
the proxy's HTTPS client trusts pipelock's bumped certs.
Consequence: pipelock's allowlist + body scanner now sit in the
cred-proxy egress path the same way they sit in front of direct
agent traffic. The cred-proxy upstream hosts (api.github.com,
github.com, gitea hosts, registry.npmjs.org) come OFF
pipelock's passthrough_domains. Only api.anthropic.com remains
on passthrough (LLM body content legitimately trips DLP).
PRD 0010 updated to reflect all three. Tests adjusted: the
"cred-proxy hosts go on passthrough" assertion in
test_pipelock_allowlist flips to "they don't", a new
TestIsGitPushRequest exercises the smart-HTTP refusal predicate,
and the gitconfig renderer tests cover the per-host suppression
matrix.
git-gate holds an SSH IdentityFile for push/fetch; cred-proxy holds
a PAT for HTTPS REST API calls. The two brokers are orthogonal —
the common dev setup names both on the same host (e.g. gitea.dideric.is
SSH for push, gitea.dideric.is PAT for `tea pr create`).
The original PRD 0010 wording called this a "configuration smell"
and rejected it at parse time. That was wrong; this drops the
overlap rejection from the validator and updates the PRD prose to
match. Tests flip from "rejection" to "coexistence" assertions.
Make the cred-proxy a per-bottle sidecar container on the bottle's
internal docker network instead of a root-owned process inside the
agent container. The boundary becomes container namespace
separation, matching pipelock and git-gate. Update summary,
problem, goals, in-scope, architecture diagram, components,
existing code touched, external deps, and open questions; add a
"Considered alternatives" section recording the rejected
in-container shape.
Per-bottle reverse proxy that holds API tokens (Anthropic OAuth,
GitHub PAT, Gitea PAT, npm) in a root-owned process; agent gets
only URLs in its environ. AWS / SigV4 explicitly out of scope.
- README architecture diagram drops the socat/ssh image box and
the agent's ~/.ssh/config; the prose-bullets section drops the
ssh image; the manifest example swaps `ssh:` for `git:` so
someone copy-pasting it picks up the new shape.
- claude-bottle.example.json: `default` bottle's `"ssh": []` is
gone (now just an empty bottle); the gitea-dev example already
uses `git:` since the ExtraHosts work.
- PRD 0007 carries a "Superseded by PRD 0009" header at the top
with a one-paragraph block explaining why; the file stays so
the rationale of the prior design is still in-tree.
- git_gate.py: drop the now-stale shadow-route mention from a
docstring (the validator went away in the manifest layer).
ssh-gate was built for non-git SSH (PRD 0007), but every
upstream currently declared in any bottle is a git remote, and
those now flow through git-gate (PRD 0008) with credential
isolation, gitleaks scanning, and `insteadOf` URL rewrites.
ssh-gate is left doing L4 forwarding with no gating value over
git-gate's path; carrying it means a redundant sidecar lifecycle,
a shadow-route validator between bottle.ssh and bottle.git, and
a third place to keep an SSH identity in sync.
Goal is straightforward deletion: bottle.ssh becomes a parse
error pointing at bottle.git, the SshEntry / SSHGate / socat
provisioner / pipelock allowlist branch all go away, and PRD
0007 carries a "Superseded by PRD 0009" header so the rationale
of the prior design stays in the tree.
- example manifest swaps the gitea-dev bottle from ssh: to git:
and shows ExtraHosts pinning gitea.dideric.is to its Tailscale IP
- README's git-gate paragraph names the field and the case it
solves (upstream resolvable on the host but not from the gate
container's default DNS)
- PRD 0008's manifest-field bullet mentions the field for parity
The gate now fronts every git operation, not just push. Fetch
(clone, pull, ls-remote) is mirrored via git daemon's
--access-hook running 'git fetch origin --prune' against the
real upstream before each upload-pack; fail-closed if upstream
is unreachable so the agent never serves stale data.
Push path is unchanged in concept (gitleaks gate → forward) but
the hook now pushes to 'origin' rather than 'upstream', matching
the remote name the entrypoint configures.
Per-bottle sidecar that fronts the agent's git remotes, runs
gitleaks via a pre-receive hook, and only forwards to the real
upstream on a clean scan. Upstream push credentials live in the
gate, not the agent — so a misbehaving agent cannot push a
secret-bearing commit past it.
Bug: git fetch failed with "connect to host
claude-bottle-ssh-gate-implementer port 30009: Connection refused".
OpenSSH treats a URL-supplied port (the user's remote was
ssh://git@gitea.dideric.is:30009/...) as overriding the
~/.ssh/config Port directive, so even though the config wrote
Port 30000 the agent dialed :30009 — where nothing was listening
because the gate had been assigned BASE_LISTEN_PORT + index.
Fix: the gate's listen port now equals the upstream port. Same
script, same socat, just port = entry.Port. Two entries on the
same upstream port are rejected at prepare time (the gate is one
container with a flat port space).
Re-smoked: probe nc github.com via the gate at :22, banner came
back as expected.
PRD 0007 updated to record the design refinement.
The gate's agent-facing leg sits on the `--internal` network, so
the forwarder image cannot rely on apk/apt at startup. Surfaced
by the DNS spike — a placeholder using `apk add socat` died
silently and gave a false-negative DNS-on-internal result.
Spike: container on a `--internal` user-defined network resolves
another container's name via the embedded resolver at 127.0.0.11
and reaches it over TCP, while egress to the public internet
remains blocked. The PRD's design assumption holds — no design
change needed.
PRD 0006 enabled pipelock's native TLS interception, which broke
git fetch over SSH from inside the agent: pipelock's SNI gate
rejects the SSH banner that follows CONNECT. Document the
architectural fix — a dedicated per-agent TCP-forwarder sidecar
built from bottle.ssh entries — so pipelock can stay maximally
strict on the HTTPS path with no SSH carve-outs.
After the open-question walkthrough, all four collapsed:
- Q1 (mount semantics): resolved to `docker cp` between
`docker create` and `docker start`, mirroring the existing
pipelock YAML handling. No bind mount, no UID/permission
concern. Folded into §Proposed Design > CA lifecycle as
"Sidecar install".
- Q2 (cert validity / TTL): pre-decided in the question text.
Per-bottle ephemerality is enforced by regenerating per launch,
not by short validity windows. Pipelock's defaults are fine.
Folded into §Proposed Design as a one-line "Per-bottle
ephemerality" note.
- Q3 (`passthrough_domains` shape): not v1 scope; the shape is
pre-recorded so the follow-up is mechanical. Moved into
§Out of scope.
- Q4 (stage-dir cleanup ordering): reading start.py confirmed
the ExitStack-then-outer-finally order is correct. Folded into
§Proposed Design as a "Teardown" note.
The §Open questions section is dropped. None of the four was a
real design question — they were verifications and pre-decided
items left in for defensiveness.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Supersedes the abandoned PR #8 (`mitmproxy-tls-interception`),
which built a mitmproxy + addon chain on the (falsified) premise
that pipelock could not MITM. Empirical proof from the impl-time
spike: with `tls_interception: { enabled: true, ca_cert, ca_key }`
in pipelock's config, pipelock answered a credential POST over
HTTPS with `STATUS=403 / body: blocked: request body contains
secret: GitHub Token` and emitted both `scanner:"tls_intercept"`
and `scanner:"body_dlp"` events. Standalone, no second proxy.
Net change vs PR #8: one sidecar instead of two, no vendored
addon, no addon-verdict pattern matching, no HTTPS-trust /
DNS / lookup workarounds. Same end-state behavior — pipelock's
DLP fires on plaintext for HTTPS hosts in the allowlist.
Also cleaning up the now-stale TLS-research notes:
- `docs/research/tls-mitm-for-pipelock.md` is removed. Its
entire premise (mitmproxy in front of pipelock) is moot now
that pipelock does the work natively. The mechanics of CONNECT
bumping and the CA-lifecycle considerations it documented are
the same as what pipelock implements; the PRD restates the
parts that matter for the integration.
- `docs/research/pipelock-assessment.md` had two stale claims
corrected: the "Pipelock does not perform TLS inspection (no
CA trust injection)" line in §Scope gaps and the
"no TLS termination" cell in the comparison table. Both now
point at the `tls_interception` config and `pipelock tls`
CLI instead.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Renames the file and rewrites the body around what actually shipped:
class-based BottleBackend ABC (not a free create_docker_bottle
function), the two-phase prepare/launch split, the backend/docker/
subpackage layout, env.py reshaped into a backend-neutral ResolvedEnv,
and PipelockProxy split between top-level and backend/docker/.