Commit Graph

21 Commits

Author SHA1 Message Date
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 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 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 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 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 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 e45cd2fb07 test(dry-run): skip docker-state guard under act_runner
test / unit (push) Successful in 13s
test / integration (push) Successful in 12s
The no-side-effects assertion calls `docker network ls` and
`docker ps -a` to verify the dry run created nothing. Inside the
Gitea Actions job container, those exit non-zero against the
host-mounted docker socket — the same act_runner topology issue
that already excludes other integration tests from CI (see
docs/ci.md). The failure was silently swallowed under the default
check=False; the recent style sweep that added check=True surfaced
it.

Gate the docker-enumerating check on GITEA_ACTIONS so the JSON
contract — the more useful part of the test — keeps running on CI.
Consolidate the two count helpers into one that surfaces stderr in
the failure message instead of raising a context-free
CalledProcessError, so the next docker surprise is debuggable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 11:50:48 -04:00
didericis 427ef96e3f feat(pipelock): enforce DLP body-scan hits by default
test / unit (push) Successful in 19s
test / integration (push) Failing after 21s
Adds bottle.egress.dlp_action ("block" | "warn", default block) and
wires it into pipelock as request_body_scanning.action. Pipelock's
own default is "warn", which previously meant claude-bottle detected
credential patterns in outbound bodies but forwarded the request
anyway.

The matching integration test posts a manifest env var shaped like
a GitHub PAT to api.anthropic.com via plain HTTP forward proxy so
pipelock can see the body. Pipelock answers 403 from its body-scan
layer instead of forwarding to the upstream.

Behavior change: bottles without an explicit egress.dlp_action now
block on body-scan hits. Set egress.dlp_action: "warn" to restore
the prior detect-only behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 11:39:25 -04:00
didericis 4864516b33 feat(bottle): add exec method to the bottle abstraction
test / unit (push) Successful in 11s
test / integration (push) Failing after 12s
Bottle.exec(script) -> ExecResult runs a POSIX shell script inside a
running bottle and returns captured stdout/stderr/returncode. The
Docker impl pipes the script via stdin to `docker exec -i ... sh -s`
so the source never crosses argv.

Two integration tests exercise it end-to-end through the pipelock
sidecar: a Node request to a non-allowlisted host (example.com)
returns 403 from pipelock; a Node CONNECT to an allowlisted host
(raw.githubusercontent.com) is tunneled with 200 Connection
Established. The 200/403 split on each verb is decided by pipelock
itself, isolating the allowlist decision from whatever the remote
might return.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 11:18:43 -04:00
didericis 3e7b81e7e7 test(dry-run): pin DOCKER_HOST so HOME override works on Desktop
test / unit (push) Successful in 14s
test / integration (push) Failing after 15s
The test overrides HOME to isolate the manifest under test from the
dev's real ~/claude-bottle.json. On Docker Desktop that override
also breaks docker CLI endpoint resolution, since the active context
is read from $HOME/.docker/config.json and the per-user socket lives
under $HOME/.docker/run/docker.sock. Forward the parent's resolved
endpoint via DOCKER_HOST so the subprocess reaches the same daemon
regardless of $HOME.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 10:40:41 -04:00
didericis 95a14bb8d2 style: pass explicit check= to every subprocess.run call
test / unit (push) Successful in 11s
test / integration (push) Failing after 11s
Silences pylint W1510 / ruff PLW1510 across the codebase. The choice
at each site reflects existing intent:

- check=True where the caller implicitly trusts success (docker ps /
  network ls returning stdout, docker build, exec chown/chmod inside
  provisioners).
- check=False where the caller inspects .returncode (race-retry on
  docker run, pipelock sidecar lifecycle, network plumbing, exec_claude
  propagating the session's exit code, best-effort cleanup paths).

No behavior change; check= defaults to False so the False sites are
semantically identical.
2026-05-12 10:13:56 -04:00
didericis 7fb0b8488b test(pipelock): skip sidecar smoke under act_runner
test / unit (pull_request) Successful in 13s
test / integration (pull_request) Successful in 14s
The smoke test now drives the production prepare/start path, which
calls network_create_internal. Under Gitea act_runner the docker
socket mount topology makes `docker network create --internal` fail
(or be invisible across the host/job-container boundary) — the same
limitation that test_orphan_cleanup.test_create_and_remove already
skips for. Match that skip here so CI goes green; the test still
runs in environments with a direct docker daemon.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 19:24:34 -04:00
didericis f943e14891 refactor(pipelock): take stage_dir, derive yaml_path internally
test / unit (pull_request) Successful in 11s
test / integration (pull_request) Failing after 12s
PipelockProxy.prepare now accepts (bottle, slug, stage_dir) and derives
the yaml_path itself, so callers don't need to know the filename.
DockerBottleBackend.prepare_proxy becomes a one-line wrapper whose only
caller already has bottle and slug in scope, so it's inlined and
deleted.
2026-05-11 16:50:22 -04:00
didericis 757e76add7 test(cli): tighten and relocate --format=json validation test
Move the --format=json-requires-dry-run check out of the integration
suite (it doesn't need Docker — argparse fails before any backend
runs) and tighten the assertion: previously asserted only that exit
code was nonzero, so any unrelated breakage (manifest resolution
failure, bad agent name, etc.) silently passed. Now asserts stderr
contains the actual flag-conflict message.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 16:35:55 -04:00
didericis 8f5e07af7f test(pipelock): drive sidecar smoke through production prepare/start
test / unit (pull_request) Successful in 14s
test / integration (pull_request) Successful in 23s
The old smoke test hand-rolled the docker create/cp/start sequence in
parallel with what DockerPipelockProxy.start already does, so any
divergence in production code wouldn't trip it. Rewritten to call
.prepare and .start directly and probe /health from a sibling curl
container on the same internal network — same access topology the
agent container uses in production.

In-network probing means the test no longer depends on a published
port, so it can run under act_runner (where host-loopback port
publishing isn't reachable from the job container).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 16:23:43 -04:00
didericis beb0c9d58f feat(cli): add --format=json to start --dry-run for machine-readable plan
BottlePlan gains a to_dict method (abstract on the base, implemented
on DockerBottlePlan) returning a JSON-serializable view of the resolved
plan. `cli.py start --dry-run --format=json` prints it to stdout and
exits zero. --format=json without --dry-run is rejected — emitting JSON
during a real launch would race the y/N prompt.

The dry-run integration test now parses the JSON and asserts on
structured fields (agent, bottle, runtime, hosts sorted+deduped, etc.)
instead of regex-matching the human-readable preflight stdout. That
kills the magic-"8 hosts allowed" coupling — adding a new baked
default doesn't break the test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 16:23:24 -04:00
didericis 4462863d56 test: reorganize suite into unit/integration/canaries directories
Replace the hand-maintained INTEGRATION_NAMES classifier (and the
bespoke run_tests.py around it) with a directory-driven split:

  tests/unit/         unit tests, always run
  tests/integration/  Docker-dependent, skip cleanly without Docker
  tests/canaries/     upstream-regression checks, opt-in via
                      CLAUDE_BOTTLE_RUN_CANARIES=1

The pinned-pipelock-image check moves to the canary suite — it tests
upstream packaging, not our code, so it shouldn't gate every dev push.
A scheduled canaries.yml workflow runs it weekly.

The manifest-runtime tests collapse the four assertRaises cases for
distinct 'runtime' values into one subTest loop and drop the
error-message-wording assertions; the contract is "any value is
rejected", not "the error literally contains 'auto-detect'".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 16:23:02 -04:00
didericis 399ed93dc8 refactor: convert project from bash to Python
Replaces cli.sh + lib/*.sh with a claude_bottle/ Python package and a
cli.py entry point. No external dependencies — uses only Python's
stdlib (json, subprocess, getpass, tempfile, argparse, re, etc.).

- claude_bottle/{log,docker,manifest,env_resolve,network,pipelock,
  skills,ssh,cli}.py mirror the previous lib/*.sh modules.
- Tests converted to unittest under tests/test_*.py with a stdlib
  runner at tests/run_tests.py (unit | integration | path).
- .githooks/commit-msg ported to Python; same Conventional Commits rules.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:26:58 +00:00
didericis ba7616a4ae PRD 0001: Per-agent egress proxy via pipelock (#1) 2026-05-08 01:56:43 -04:00