Commit Graph

176 Commits

Author SHA1 Message Date
didericis fe9d05664c docs: switch cred-proxy to sidecar shape
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 28s
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.
2026-05-13 00:40:16 -04:00
didericis 7dc3914abc docs: align cred-proxy architecture diagram
test / unit (pull_request) Successful in 14s
test / integration (pull_request) Successful in 26s
Trim one trailing space from the four arrow/HTTPS rows and add
one dash to the bottle-container bottom edge so all box-bound
lines are 68 columns.
2026-05-13 00:23:09 -04:00
didericis 2a687449d4 docs: add PRD 0010 for credential proxy
test / unit (pull_request) Successful in 13s
test / integration (pull_request) Successful in 19s
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.
2026-05-13 00:18:55 -04:00
didericis 3d9103d5b5 Merge pull request 'PRD 0009: Remove ssh-gate and bottle.ssh' (#13) from deprecate-ssh-gate into main
test / unit (push) Successful in 16s
test / integration (push) Successful in 23s
2026-05-13 00:00:59 -04:00
didericis 30d92bef48 docs: drop ssh from README/example, supersede PRD 0007 (PRD 0009)
test / unit (pull_request) Successful in 13s
test / integration (pull_request) Successful in 21s
- 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).
2026-05-12 23:57:50 -04:00
didericis 249e8cc15e test: drop ssh-gate suites and shadow-route assertions (PRD 0009)
- Delete tests/unit/test_ssh_gate.py and the fixture_with_ssh helpers.
- test_pipelock_yaml: drop the ssh-leak guard (structurally
  impossible now); the remaining tests switch to fixture_minimal.
- test_pipelock_allowlist: rewrite the union/dedup test to
  exercise an egress.allowlist that duplicates a baked default
  (the property the ssh-leak assertion was hitching onto).
- test_manifest_git: shadow-route assertion becomes a legacy-ssh-
  dies-with-hint assertion, since bottle.ssh is now parse-fail.
- test_orphan_cleanup: drop the SSHGate.stop idempotency check;
  pipelock equivalent stays.
- test_dry_run_plan: drop assertions on the removed ssh_hosts /
  ssh_gate keys.

52 unit tests pass.
2026-05-12 23:54:22 -04:00
didericis 3d66ad2a86 feat(ssh-gate)!: remove ssh-gate sidecar and provisioner (PRD 0009)
Delete claude_bottle/ssh_gate.py, the DockerSSHGate sidecar,
and the provision_ssh provisioner (~/.ssh/config + ssh-agent
wiring). Unwire the gate from the abstract BottleBackend
(provision orchestration drops the ssh step,
_validate_ssh_entries goes away) and from the Docker backend
(prepare/launch lose the `gate` kwarg, bottle_plan drops the
gate_plan field, dry-run JSON drops the ssh_hosts / ssh_gate
keys, y/N preflight drops the ssh-hosts block). cli/info now
prints declared git remotes instead of ssh hosts. pipelock's
docstring picks up the git-gate framing now that there's no
PRD-0007 boundary to call out.

BREAKING (dry-run JSON): the `ssh_hosts` and `ssh_gate` keys
are gone from `start --dry-run --format=json`. Consumers should
read `git_remotes` / `git_gate` instead.
2026-05-12 23:49:58 -04:00
didericis c403d137b6 feat(manifest)!: remove SshEntry and bottle.ssh (PRD 0009)
Drop the SshEntry dataclass, the Bottle.ssh field, the shadow-
route validator, and the SSH-only _opt_port helper. A legacy
bottle.ssh key now parse-fails with a one-line hint pointing at
bottle.git (PRD 0008), which is the replacement.

BREAKING: manifests carrying bottle.ssh will not load. Migration
is per-entry: drop the ssh entry, add a git entry with a Name +
full Upstream URL + IdentityFile.
2026-05-12 23:41:09 -04:00
didericis efcafae810 docs(prds): add PRD 0009 to remove ssh-gate and bottle.ssh
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 34s
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.
2026-05-12 23:34:11 -04:00
didericis 00649d27e9 docs(research): add credential-proxy landscape and DLP-minimization framing
test / unit (push) Successful in 14s
test / integration (push) Successful in 29s
Consolidates oauth-token-exposure-to-claude.md and
tea-token-isolation-via-proxy.md into agent-credential-proxy-landscape.md,
adding a May-2026 survey of existing tools (Docker AI Sandboxes,
Cloudflare Sandbox Auth, Infisical Agent Vault, nono, Aembit, LiteLLM
CVE-2026-42208, Portkey, Helicone, etc.) and a build-vs-adopt verdict.

Adds secret-minimization-over-dlp.md explaining why pipelock's body
DLP and gitleaks's pre-receive scan cannot stop encoding/splitting
exfil, and why moving credentials out of the bottle (the git-gate
pattern, generalized) is the only robust answer.

Updates git-secret-scanning-hardening.md's reference to point at
the new consolidated landscape doc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 23:25:12 -04:00
didericis 86637453c4 Merge pull request 'git-gate: ExtraHosts on bottle.git entries' (#12) from git-gate-extra-hosts into main
test / unit (push) Successful in 13s
test / integration (push) Successful in 23s
2026-05-12 23:22:26 -04:00
didericis 9b7bcc0149 docs(git-gate): document ExtraHosts on bottle.git entries
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 19s
- 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
2026-05-12 23:18:46 -04:00
didericis 102e29ee77 feat(git-gate): plumb ExtraHosts through to docker --add-host
GitGateUpstream carries each entry's extra_hosts; a new
git_gate_aggregate_extra_hosts() merges them into one map for the
gate container's /etc/hosts. Same host -> same IP is harmless
duplication; same host -> different IPs is a manifest bug
(/etc/hosts is per-container, not per-upstream) and dies with
the conflicting upstream names.

DockerGitGate.start passes one --add-host host:ip per merged
entry on docker create. Empty map (the default) emits no flags
and is a no-op for bottles that don't need DNS overrides.
2026-05-12 23:18:46 -04:00
didericis 4c6610e222 feat(manifest): add ExtraHosts to bottle.git entries
Optional `ExtraHosts: { hostname: ip }` map per git entry. The
docker backend will surface these to the gate sidecar via
--add-host so the gate can resolve upstreams whose default
container DNS doesn't point at the reachable IP (e.g.
Tailscale-only hosts with a public DNS A record pointed
elsewhere). The agent-side insteadOf rewrite still keys off
the original hostname, so the manifest's Upstream URL stays
human-readable.
2026-05-12 23:18:46 -04:00
didericis a37441961d Merge pull request 'PRD 0008: Git gate' (#11) from git-gate into main
test / unit (push) Successful in 11s
test / integration (push) Successful in 24s
2026-05-12 23:16:45 -04:00
didericis 76a56c0700 docs(readme): git-gate is now a bidirectional mirror
test / unit (pull_request) Successful in 11s
test / integration (pull_request) Successful in 21s
Architecture diagram + bullet now reflect that the gate fronts
every git operation, not just push: pre-receive gitleaks-gates
the push path; an access-hook refreshes from upstream before each
upload-pack so fetch / clone / pull / ls-remote see whatever the
upstream has at that moment (fail-closed if unreachable).
2026-05-12 22:36:16 -04:00
didericis f9d9e9cf33 test(git-gate): bidirectional mirror round-trip
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 34s
A pair of integration tests against a real sshd-based "upstream"
sibling container that prove every operation through the gate is
observably equivalent to the same operation against the upstream:

  - test_clone_and_refetch_reflect_upstream: clone via gate
    returns the upstream's current commit; an out-of-band commit
    on the upstream shows up via the gate on the next ls-remote.
  - test_push_through_gate_lands_on_upstream: a clean push routed
    through the gate lands on the upstream's bare repo.

The upstream container is a tiny inline-built alpine image with
openssh-server, a `git` user (passwd -u so sshd doesn't reject
the locked account), and a baked bare repo seeded with one
commit. Host keys are baked in at build so the test can pin
KnownHostKey on the manifest entry before the container starts.

While wiring this up the access-hook gained a one-shot HEAD
sync: `git init --bare` defaults HEAD to refs/heads/master, and
upstreams that use main would leave the bare repo's HEAD
unresolvable — clones came through but the working tree was
empty. The hook now does a `rev-parse --verify HEAD` check
after the first fetch and runs `ls-remote --symref` to repoint
HEAD if it doesn't resolve. One extra round-trip on first
fetch only.
2026-05-12 22:34:38 -04:00
didericis 824527497c feat(git-gate): rewrite both fetch and push via insteadOf
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 16s
The agent's ~/.gitconfig now uses insteadOf (not pushInsteadOf),
so every git operation against a declared upstream — push, fetch,
clone, pull, ls-remote — routes through the gate. Matches the
gate's now-bidirectional design: fetch is mirrored via the
access-hook, push is gated via gitleaks.
2026-05-12 21:38:44 -04:00
didericis fdd06c54d2 feat(git-gate): mirror fetch through access-hook (bidirectional)
test / unit (pull_request) Successful in 11s
test / integration (pull_request) Successful in 14s
The gate is now a transparent mirror, not push-only. Per-repo
init now runs `git remote add --mirror=fetch origin <url>` so a
later `git fetch origin` mirrors the upstream's full ref graph at
canonical paths. The pre-receive hook forwards accepted refs via
`git push origin` (renamed from upstream).

New: an access-hook script wired via `git daemon --access-hook`
runs `git fetch origin --prune` against the real upstream before
every upload-pack request (clone, fetch, pull, ls-remote). On
upstream error the hook exits non-zero — the agent's fetch fails
rather than the gate serving stale data.

The pre-existing smoke test (ls-remote against unreachable
upstream returns refs) had to invert: under the bidirectional
design any ls-remote success is necessarily a success against
the upstream, so the unreachable-upstream case now correctly
fails closed.
2026-05-12 21:37:04 -04:00
didericis ae7e22065f docs(prds): expand PRD 0008 to bidirectional mirror scope
test / unit (pull_request) Successful in 11s
test / integration (pull_request) Successful in 18s
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.
2026-05-12 21:26:19 -04:00
didericis bea433015f docs(readme): add git-gate to architecture diagram
test / unit (pull_request) Successful in 13s
test / integration (pull_request) Successful in 17s
Bumps the sidecar count from two to up to three; the diagram and
bullet list now cover the git-gate alongside pipelock and ssh-gate,
including the ~/.gitconfig pushInsteadOf wiring that fires the
agent's git push through the gate.
2026-05-12 21:19:20 -04:00
didericis 89981f9048 test(git-gate): integration smoke + secret-blocking push
test / unit (pull_request) Successful in 15s
test / integration (pull_request) Successful in 36s
Two integration tests against a real Docker daemon:

  - test_ls_remote_succeeds_against_fresh_gate: a freshly-started
    gate has its empty bare repo exported via git daemon; ls-remote
    from a sibling container on the internal network returns no
    refs and exits 0.

  - test_push_with_secret_is_rejected: the PRD 0008 success
    criterion — a push containing an AKIA-shaped synthetic that
    trips gitleaks's aws-access-token rule is rejected by the
    pre-receive hook with a non-zero exit on the client and a
    gitleaks rejection in the response.

Dockerfile.git-gate switches base to zricethezav/gitleaks (alpine
3.22 + gitleaks v8.30.1, pinned by digest) since gitleaks isn't
packaged for alpine, and adds git-daemon (the sub-package the
listener needs; the core git binary in the base doesn't include
the daemon).
2026-05-12 21:17:42 -04:00
didericis f787edb861 feat(git-gate): wire DockerGitGate through prepare/launch/plan
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 14s
DockerBottleBackend now instantiates a DockerGitGate alongside
DockerPipelockProxy and DockerSSHGate; the prepare step lifts
bottle.git into a GitGatePlan stored on DockerBottlePlan, and
launch starts/stops the sidecar in the same ExitStack as the
other two (only when bottle.git is non-empty).

bottle_plan.print now surfaces git remotes and per-upstream gate
forwards in the y/N preflight; to_dict adds git_remotes and
git_gate keys to the dry-run JSON payload for CLI consumers.
PRD: docs/prds/0008-git-gate.md
2026-05-12 21:06:08 -04:00
didericis 509b1b61e2 feat(git-gate): provision ~/.gitconfig pushInsteadOf in the bottle
test / unit (pull_request) Successful in 16s
test / integration (pull_request) Successful in 14s
provision_git now does two things: copy the host cwd's .git (when
--cwd is set, existing behavior) and write ~/.gitconfig with
pushInsteadOf rules for each bottle.git entry. A 'git push <real
upstream URL>' from inside the agent transparently rewrites to
'git://<gate>/<name>.git' so the gate gets first crack at the
incoming refs.

pushInsteadOf (not insteadOf) keeps fetch on the original URL —
v1 of the git-gate is push-only scope per PRD 0008. The render
helper is exposed for testing without docker.
2026-05-12 21:01:00 -04:00
didericis 2d955a5512 feat(git-gate): add DockerGitGate sidecar lifecycle + image
test / unit (pull_request) Successful in 16s
test / integration (pull_request) Successful in 15s
Dockerfile.git-gate builds a small alpine image with git,
openssh-client, and gitleaks; the directory layout the entrypoint
and per-upstream cp's expect is pre-created in the image so docker
cp can target paths beneath /etc/git-gate and /git-gate/creds at
container-create time (cp doesn't create intermediate dirs).

DockerGitGate.start mirrors DockerSSHGate's shape: build, create,
cp the rendered entrypoint + hook + per-upstream identity files
(plus a known_hosts file synthesized from KnownHostKey when set),
attach the egress network, start. build_image gains an optional
dockerfile= argument so the gate can build from its own
Dockerfile in the shared context.

PRD: docs/prds/0008-git-gate.md
2026-05-12 20:58:51 -04:00
didericis 2fb90f2087 feat(git-gate): add platform-agnostic GitGate abstraction
test / unit (pull_request) Successful in 19s
test / integration (pull_request) Successful in 17s
Mirrors the SSHGate/PipelockProxy shape: a host-side prepare that
lifts bottle.git into a tuple of GitGateUpstreams and renders two
shell scripts under stage_dir — the gate's entrypoint (which
initializes a bare repo per upstream and execs git daemon
--enable=receive-pack) and the shared pre-receive hook
(gitleaks-scan, then forward each accepted ref to the real
upstream using the per-repo credential).

Failure in either hook phase aborts the push so the agent sees a
real rejection, not a silent success. KnownHostKey absence is
fail-closed: the hook refuses to forward without a pinned key
rather than TOFU-trusting the upstream from inside the gate.

PRD: docs/prds/0008-git-gate.md
2026-05-12 20:54:38 -04:00
didericis 5c5e9f817e feat(manifest): add bottle.git field for git-gate upstreams
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 15s
Each entry pairs a Name (local alias the gate exposes) with an
ssh:// Upstream URL, an IdentityFile the gate uses to push to
that upstream, and an optional KnownHostKey for upstream
host-key pinning. The Upstream URL is parsed at construction
into UpstreamUser/Host/Port/Path so downstream code doesn't
re-parse.

Two cross-validation rules: Names must be unique within a
bottle (each maps to a distinct bare repo), and no git entry's
(host, port) may overlap an ssh entry's (Hostname, Port) — the
same upstream reachable two ways would let a misbehaving agent
route around the gitleaks-bearing git-gate via the L4 ssh-gate.

PRD: docs/prds/0008-git-gate.md
2026-05-12 18:48:14 -04:00
didericis c91395425c docs(prds): add PRD 0008 git gate
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 13s
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.
2026-05-12 18:24:33 -04:00
didericis 4790f8bcc1 docs(readme): add architecture diagram for agent/pipelock/ssh-gate
test / unit (push) Successful in 12s
test / integration (push) Successful in 13s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 18:01:43 -04:00
didericis 4f0cd0f782 fix(pipelock): passthrough api.anthropic.com so Claude auth/chat works
test / unit (push) Successful in 15s
test / integration (push) Successful in 15s
Pipelock's BIP-39 seed-phrase scanner fires on Anthropic Messages API
bodies because user-authored conversation text can hit 12 consecutive
BIP-39 dictionary words that pass the checksum, returning a 403
`blocked: request body contains secret: BIP-39 Seed Phrase` that the
Claude CLI surfaces as `Please run /login`. Pipelock's `suppress`
section only covers git/file findings, not the inline body scanner,
so the recommended treatment for LLM endpoints is
`tls_interception.passthrough_domains`: CONNECT is still allowlist-
gated, but the body is not MITM'd. The existing body-scan integration
test moves to `raw.githubusercontent.com` so it still pins TLS body
DLP on non-passthrough'd hosts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 17:55:05 -04:00
didericis 96d2c7b7a1 docs(research): add note on git secret-scanning as defense-in-depth
test / unit (push) Successful in 12s
test / integration (push) Successful in 15s
Threat-models the case where a credential ends up in a tracked
file and is git-pushed to a public remote — the secret is
compromised the instant the push lands (events API, scrapers),
not at merge time. Recommends gitleaks as the smallest-blast-
radius layer to add: Go binary, MIT, offline, scans full history,
hookable from the existing .githooks/.

No code or workflow change; just the research note.
2026-05-12 16:24:06 -04:00
didericis 9827b86063 Merge pull request 'PRD 0007: SSH egress gate' (#10) from ssh-egress-gate into main
test / unit (push) Successful in 11s
test / integration (push) Successful in 13s
2026-05-12 16:21:11 -04:00
didericis a3d77cd015 fix(ssh-gate): listen on the upstream port so URL-supplied ports work
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 12s
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.
2026-05-12 16:19:07 -04:00
didericis a7633977de test(ssh-gate): assert SSHGate.stop is no-op on missing sidecar
test / unit (pull_request) Successful in 14s
test / integration (pull_request) Successful in 13s
PRD 0007: the launch ExitStack calls gate.stop on every failure
path, so an early bring-up error (where the gate container was
never created) must not raise from teardown. Mirrors the existing
DockerPipelockProxy.stop assertion.

The orphan-container enumeration in cleanup.py already covers
ssh-gate containers via its `claude-bottle-` name prefix filter —
no code change there.
2026-05-12 16:09:53 -04:00
didericis 6130ea385f refactor(pipelock): drop bottle.ssh carve-outs
PRD 0007: SSH traffic now flows through the per-agent ssh-gate
sidecar, so pipelock should know nothing about bottle.ssh.

Removed:
- pipelock_bottle_ssh_hostnames, _trusted_domains, _ip_cidrs.
- The trusted_domains / ssrf blocks built from ssh entries.
- pipelock_proxy_host_port — its last caller (the ssh provisioner)
  is gone.
- is_ipv4_literal — only used to classify ssh hostnames into
  trusted_domains vs ssrf.ip_allowlist, both of which are gone.

api_allowlist now derives solely from baked-in defaults +
bottle.egress.allowlist. Tests updated to pin the new shape and
assert ssh hostnames do NOT leak into pipelock's config.
2026-05-12 16:08:26 -04:00
didericis ce948db0b7 feat(ssh-gate): retarget ssh provisioner at the new gate
PRD 0007: stop tunneling ssh through pipelock. Each Host block in
the agent's ~/.ssh/config now points at the gate container + the
per-entry listen port; HostKeyAlias preserves host-key validation
against the real upstream name, and CheckHostIP=no skips the
resolved-IP path (which would otherwise hit the gate's IP).
known_hosts collapses to a single entry per upstream keyed on the
alias.

The pipelock_proxy_host_port import is gone from this module; the
function itself becomes dead code and gets removed alongside the
broader pipelock SSH carve-outs in the next commit.
2026-05-12 16:05:22 -04:00
didericis 2533f8a00b feat(ssh-gate): wire gate into DockerBottlePlan, prepare, launch
PRD 0007: thread the DockerSSHGate through the bottle lifecycle.

- DockerBottlePlan gains gate_plan: SSHGatePlan.
- prepare.resolve_plan accepts a gate and renders its entrypoint
  script next to the pipelock yaml.
- launch.launch starts the gate sidecar after pipelock (so it's on
  the same internal + egress networks) and registers its stop in
  the ExitStack. Skipped when the bottle has no ssh entries.
- DockerBottleBackend instantiates DockerSSHGate alongside the
  pipelock proxy.
- bottle_plan.print + to_dict surface the upstream table so
  --dry-run shows the per-host listen-port mapping.

ssh_config provisioning still points at pipelock; that swap lands
in the next commit so this one stays a pure wiring change.
2026-05-12 16:03:55 -04:00
didericis c05d1ddcdb feat(ssh-gate): add DockerSSHGate sidecar lifecycle
PRD 0007: Docker-specific start/stop for the SSH egress gate.
Mirrors DockerPipelockProxy: docker create on the internal
network with /bin/sh entrypoint, docker cp the staged entrypoint
script in, attach to the egress network, docker start. Image is
alpine/socat pinned by digest — self-sufficient at boot so the
gate's agent-facing leg can stay on the --internal network.

Not yet wired into the bottle launch path; that lands next.
2026-05-12 15:57:56 -04:00
didericis f7fb691626 feat(ssh-gate): add abstract SSHGate + plan dataclass
First piece of PRD 0007: the per-agent SSH egress gate that will
let pipelock stop seeing SSH traffic. This commit only lands the
backend-agnostic surface — the SSHGate ABC, SSHGatePlan, the
listen-port assignment (BASE_LISTEN_PORT + index), and the
entrypoint-script renderer. Backend wiring lands in follow-up
commits.
2026-05-12 15:56:52 -04:00
didericis b2927b1483 docs(prd): note gate image must be self-sufficient at boot on 0007
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 13s
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.
2026-05-12 15:50:34 -04:00
didericis cb0f0f133d docs(prd): resolve gate-DNS open question on 0007
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 14s
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.
2026-05-12 15:48:55 -04:00
didericis 02a0fe679d docs(prd): 0007 SSH egress gate
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 15s
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.
2026-05-12 15:41:26 -04:00
didericis 6eb898ffca chore(git): ignore .claude/ local state
test / unit (push) Successful in 13s
test / integration (push) Successful in 13s
The directory carries this session's scheduled-tasks lock file and
agent-memory cache; both are per-user state, not project artifacts.
Stops `git status` from listing it on every command.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 15:13:43 -04:00
didericis 235e9ff4ea Merge pull request 'PRD 0006: pipelock native TLS interception' (#9) from pipelock-tls-interception into main
test / unit (push) Successful in 12s
test / integration (push) Successful in 13s
2026-05-12 15:03:23 -04:00
didericis d3115ae5fd test(pipelock): HTTPS integration tests for the bumped path
test / unit (pull_request) Successful in 11s
test / integration (pull_request) Successful in 13s
Fourth and final step of PRD 0006. Two new end-to-end tests pin
the two paths through pipelock's tls_interception layer.

- test_pipelock_blocks_secret_https_post: posts a GitHub-PAT-shaped
  body to api.anthropic.com over HTTPS through the bottle. With
  pipelock now bumping the CONNECT and seeing the decrypted body,
  it returns 403 with the documented `blocked: request body
  contains secret: GitHub Token` body. The probe is a single curl
  invocation — curl natively does CONNECT through HTTPS_PROXY, the
  agent's trust store now contains pipelock's CA, no hand-rolled
  TLS in the test.

- test_pipelock_allows_normal_https: GETs git's README from
  raw.githubusercontent.com (a baked-in allowlist host). 200 +
  non-zero body length proves the full chain works:
  pipelock_tls_init → docker cp of CA into sidecar → bumped CONNECT
  → provision_ca installed CA in agent → curl trusts pipelock's
  bumped leaf → body forwarded back through the tunnel.

- test_pipelock_sidecar_smoke: pre-existing direct-start smoke
  test updated to call pipelock_tls_init and populate the CA
  paths on the plan. (The full launch flow does this in launch.py;
  this test exercises the proxy class in isolation.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 15:01:17 -04:00
didericis fb10c8dd8a feat(bottle-plan): render TLS interception in the dry-run preflight
Third step of PRD 0006. The preflight now surfaces the TLS-
intercept layer so the operator sees it before agreeing to launch.

- Text output: one new line under the egress summary
  ("tls intercept : pipelock (per-bottle ephemeral CA, generated
  at launch)").
- JSON output (--format=json contract): new
  egress.tls_interception: { enabled: true, ca_fingerprint: null }
  block. Fingerprint is always null at dry-run because the CA
  only exists after launch; real launches print it as a stderr
  log line from provision_ca.
- Pin the new shape in the dry-run integration test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:52:53 -04:00
didericis 86a9b499bc feat(provision): install pipelock CA into the agent + add curl
test / unit (pull_request) Successful in 16s
test / integration (pull_request) Successful in 15s
Second step of PRD 0006. With pipelock now doing the bumping, the
agent's TLS library has to trust pipelock's per-bottle CA — or
every CONNECT to api.anthropic.com is a self-signed-cert error.

- BottleBackend.provision gains a non-abstract `provision_ca`
  with a default no-op (so non-Docker backends aren't forced to
  implement TLS interception) and orchestrates
  ca → prompt → skills → ssh → git. CA install runs first so the
  agent's trust store is rebuilt before anything else in the
  agent makes a TLS call.

- New backend/docker/provision/ca.py: docker-cp's the CA cert
  into the agent at /usr/local/share/ca-certificates/...,
  `update-ca-certificates`, then emits a one-line stderr log
  with the SHA-256 fingerprint (stdlib `ssl` + `hashlib`; no
  subprocess for crypto). Module-level constants AGENT_CA_PATH
  and AGENT_CA_BUNDLE are imported by launch.py so the env
  trio set at docker run time matches the paths the provisioner
  writes.

- launch.py: rebinds `plan` after `dataclasses.replace`s on the
  pipelock proxy plan so provision_ca (which reads
  `plan.proxy_plan.ca_cert_host_path`) sees the populated CA
  paths. Three new -e flags on the agent's docker run for the
  NODE_EXTRA_CA_CERTS / SSL_CERT_FILE / REQUESTS_CA_BUNDLE trio.

- Dockerfile: adds curl to the apt-get install line. curl
  natively respects HTTPS_PROXY and sends CONNECT directly —
  the agent doesn't need OS-level DNS for external hostnames
  (pipelock resolves them on its side of the bumped tunnel).
  This is the "simple HTTPS request" path the earlier turn
  needed and Node's stdlib https.request couldn't provide.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:50:20 -04:00
didericis 3755e66abe feat(pipelock): enable tls_interception with per-bottle ephemeral CA
First step of PRD 0006. Pipelock now does the CONNECT bumping that
PR #8's mitmproxy chain was supposed to provide — natively, in the
same single sidecar PRD 0001 wired up.

- claude_bottle/pipelock.py: pipelock_build_config grows optional
  ca_cert_path / ca_key_path kwargs. When both are passed the
  rendered YAML carries a `tls_interception: { enabled: true,
  ca_cert, ca_key }` block. PipelockProxy gains class-level
  CA_CERT_IN_CONTAINER / CA_KEY_IN_CONTAINER constants that
  subclasses set to wherever they place the CA inside the
  sidecar. PipelockProxyPlan gains ca_cert_host_path /
  ca_key_host_path fields (default empty Path() — sentinel for
  "not yet populated", filled by launch via dataclasses.replace).

- claude_bottle/backend/docker/pipelock.py: new
  pipelock_tls_init(stage_dir) helper runs `pipelock tls init`
  in a one-shot container against a host-mounted scratch dir.
  DockerPipelockProxy sets its class constants to
  /etc/pipelock-ca.pem and /etc/pipelock-ca-key.pem; .start
  docker-cp's the cert + key into those paths between
  `docker create` and `docker start`. Pipelock runs as root in
  its distroless image, so no chown is needed (verified).

- claude_bottle/backend/docker/launch.py: calls pipelock_tls_init
  between network creation and proxy.start. Prepare stays
  side-effect-free on docker; the one-shot ca-init container
  only runs on a real launch, not on `start --dry-run`.

- tests/unit/test_pipelock_yaml.py: new assertions that
  pipelock_build_config emits the tls_interception block only
  when both paths are supplied (and rejects a half-set pair),
  plus a test that the docker proxy's prepare plumbs the
  in-container paths through to the rendered YAML.

The end-to-end "bumping actually fires" assertion lands in
chunk 4 (HTTPS integration tests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:45:36 -04:00
didericis f44e884d8a docs(prd): fold 0006 walkthrough resolutions into the design
test / unit (pull_request) Successful in 15s
test / integration (pull_request) Successful in 14s
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>
2026-05-12 14:22:59 -04:00
didericis 6716f091c1 docs(prd): add 0006, enable pipelock's native TLS interception
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 13s
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>
2026-05-12 14:15:44 -04:00