Skip to content

feat(kepler): geharde SSH-server in de sandbox voor GitKraken Kepler-remote - #92

Draft
jonrust-minbzk wants to merge 109 commits into
mainfrom
experiment/kepler-ssh-sandbox
Draft

feat(kepler): geharde SSH-server in de sandbox voor GitKraken Kepler-remote#92
jonrust-minbzk wants to merge 109 commits into
mainfrom
experiment/kepler-ssh-sandbox

Conversation

@jonrust-minbzk

@jonrust-minbzk jonrust-minbzk commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Optionele opt-in (INSTALL_SSHD=true) die een gehard sshd ín de sandbox draait, zodat GitKraken Kepler via SSH een Claude-agent-sessie in de gecureerde, gefirewallde image kan draaien i.p.v. op de kale host/VM.

Wat het toevoegt

  • INSTALL_SSHD=true (default uit) installeert + hardt OpenSSH-server: pubkey-only, geen root, alleen user claude, geen X11.
  • compose.override.kepler.yml publiceert de poort loopback-only (127.0.0.1:2222); pubkey komt runtime via KEPLER_SSH_PUBKEY (geen baked keys).
  • Entrypoint start sshd niet-fataal; PATH-fix voor non-interactieve sessies.
  • claude-sandbox/kepler/smoke-test.sh: host-side verificatie van poortbinding, login, PATH, hardening-weigeringen, firewall + regressie-guards.
  • README-sectie 'Kepler (SSH-remote)' + Dockerfile-comments + build-toggle-tabel.

Twee niet-triviale problemen opgelost (met smoke-test geborgd)

1. PerSourcePenalties no — OpenSSH ≥9.8 straft per bron-IP. Achter de NAT/port-forward (gvproxy op macOS, Docker's portpublish) deelt elke client één bron-IP, dus één mislukte/afgebroken connectie straft alle volgende — zelf-versterkend (~15-600s). Op een loopback-only, pubkey-only poort levert per-bron-throttling nauwelijks beveiliging op. Burst-guard in de smoke-test (sectie 6).

2. Kepler tunnel-race workaround (/etc/zsh/zprofile) — Kepler zet een ControlMaster op en draait de tunnel als ssh -N -L mux-slave; die draagt de forward over aan de master en exit binnen ~10-30 ms. De poort is dan al klaar (curl door de tunnel → HTTP 200), maar Keplers readiness-poll behandelt het child-exit als fataal vóór de poort-check → ssh exited before the tunnel ... (code 0). Server-onafhankelijk (idem tegen een kale sshd) → Kepler-bug, geen sandbox-fout. Workaround: rek de fantoom-login-shell die de mux-slave opent met een korte sleep (alleen non-interactieve login-shells; de zsh -c probe en interactieve shells blijven ongemoeid) zodat Keplers eerste poll de al-klare poort pakt. Regressie-guard in de smoke-test (sectie 7). De bug is upstream aan het Kepler-team gemeld; workaround kan eruit zodra dat gefixt is.

Beveiliging

De sshd is gehard (pubkey-only, AllowUsers claude, PermitRootLogin no, geen agent-/X11-/stream-local-forwarding, PermitOpen op de loopback-vormen) en de build faalt als de drop-in niet effectief blijkt. De daemon start met env -u ANTHROPIC_API_KEY, zodat een pre-auth-lek in OpenSSH de sleutel niet uit zijn eigen omgeving leest.

Wat het níet dekt staat in de README-sectie 'Kepler (SSH-remote)' en is bewust uitgeschreven: een geslaagde login is een volledige shell als claude, de API-sleutel blijft binnen de sessie leesbaar via /proc/1/environ, binnen het container-netwerk luistert sshd op alle adressen (#101 beperkt dat tot de gateway), het auth-spoor draagt geen tijdstempel (#102 lost dat op), en claude kan host-key-rotatie afdwingen door .ssh-host te hernoemen.

Geverifieerd

  • smoke-test.sh volledig groen op een podman-machine op macOS, inclusief beide regressie-guards, en Kepler verbindt end-to-end met de sandbox als remote. Die run is gedaan op een eerdere stand van deze branch.
  • shellcheck --severity=warning op alle gewijzigde scripts: schoon.

Expliciet NIET geverifieerd

  • Sinds die run zijn er commits bijgekomen die de smoke-test, de entrypoint en de Dockerfile raken — onder meer de env-scrub op de sshd-start, de typecontrole op het publieke host-key-pad en de Trivy-job in build-image.yml. Die stand is niet opnieuw gedraaid; herhaal de smoke-test vóór merge.
  • Arm64: PR-builds draaien alleen linux/amd64; de arm64-variant komt pas bij de push naar main.

🤖 Generated with Claude Code

ericwout-overheid and others added 30 commits June 10, 2026 13:52
Maven+Testcontainers in de sandbox via nested rootless Podman i.p.v. de
host-agent-bridge. Sluit de container->host code-execution van #44: geen
bridge, geen Docker-socket, geen --privileged. Cross-platform, lichter dan
sysbox/microVM. PoC moet seccomp/fuse/subuid/egress uitwijzen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#44)

INSTALL_PODMAN build-ARG (default false) installeert podman + fuse-overlayfs +
uidmap + passt/slirp4netns en zet subuid/subgid + rootless storage.conf.
compose.override.podman.yml.example levert /dev/fuse + seccomp-stand. PoC-map
met sample Testcontainers-project, smoke-test.sh en README (run-stappen,
ALLOWED_DOMAINS-egress, fallbacks). Niet live testbaar in deze omgeving (geen
runtime); gebruiker draait smoke-test op de host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
newuidmap faalde met "write to uid_map failed: Operation not permitted".
Root cause: AppArmor docker-default-profiel medieert writes naar
/proc/<pid>/uid_map op Debian/Ubuntu-hosts. Userns (initieel), CAP_SETUID/SETGID
(bounding set), setuid-root newuidmap en NoNewPrivs=0 waren allemaal in orde;
AppArmor was de enige overgebleven blokkade. seccomp stond al op unconfined,
label=disable dekt alleen SELinux. README-fallbacktabel bijgewerkt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nde hosts (#44)

PoC wees uit dat naïeve multi-uid rootless podman niet werkt op gehardende
Ubuntu/Tuxedo (apparmor_restrict_unprivileged_userns=1): host blokkeert élke
userns-map, en de privileged newuidmap-range faalt apart. Herziene aanpak:

- Single-uid modus: geen subuid/subgid voor claude -> podman mapt alleen eigen
  uid als root, gebruikt newuidmap niet. ignore_chown_errors=true in storage.conf.
- Custom AppArmor-profiel (flags=(unconfined) { userns, }) + setup-host.sh laadt
  het op de host. Restrictie blijft systeembreed aan; alleen deze container krijgt
  userns. Override verwijst naar het profiel i.p.v. apparmor=unconfined.
- Spec uitgebreid met PoC-bevindingen, per-setup matrix en security-trade-off.
- README herzien: per-setup flow, setup-host.sh, fallbacks.

Nog te verifieren op een gehardende host (kan niet in deze werkomgeving).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…44)

PoC-voortgang op gehardende host: AppArmor-userns-profiel lost userns op met
sysctl=1 (self-map OK), single-uid podman info werkt, en met ignore_chown_errors
slaagt image-extractie. Resterend: pasta-netwerk faalde op ontbrekend
/dev/net/tun.

- compose-override: /dev/net/tun device erbij (NET_ADMIN had de sandbox al).
- entrypoint: schrijft rootless storage.conf (single-uid + fuse-overlayfs +
  ignore_chown_errors) idempotent bij start, want een bestaand named volume
  schaduwt de baked-in image-versie.
- README-fallbacks: pasta/tun + storage.conf-shadow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iner) (#44)

Nested container start faalde op crun "open /proc/sys/net/ipv4/ping_group_range:
Read-only file system": podman zet die sysctl default, maar /proc/sys is RO in de
outer container. Entrypoint schrijft nu ook containers.conf met default_sysctls=[]
zodat crun geen sysctls probeert te zetten. README-fallback bijgewerkt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nested container start faalde op crun "mount proc: Operation not permitted":
Docker maskeert /proc-paden, waardoor de kernel (mount_too_revealing) een nieuwe
procfs in de geneste mount-namespace weigert. systempaths=unconfined heft de
masked/RO /proc-paden op de outer container op. Peelt outer-sandbox-hardening
verder af — security-trade-off staat in de override-comment en spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mvn test (surefire) sloeg *IT stil over -> groene build zonder dat de
Testcontainers-test ooit liep. Hernoemd naar *Test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#44)

PoC GESLAAGD (geverifieerd: Tests run: 1, Failures: 0, Errors: 0 — Testcontainers
alpine startte via podman). Testcontainers maakt een bridge-netwerk → netavark
riep default `nft` aan, niet in de image. iptables-driver gebruikt iptables-nft
(al aanwezig). Entrypoint zet dit nu in containers.conf.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…44)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ild-bevestiging (#44)

Andere sessie draaide een echt Quarkus-project (Redis-stack Dev-Services):
289+46 tests groen via podman in de sandbox. Gap: containers met port-wait
(Postgres/Redis) time-outten omdat Testcontainers de netavark bridge-gateway
(10.88.0.1) als host resolvet terwijl rootless podman op localhost publisht.
Fix: TESTCONTAINERS_HOST_OVERRIDE=localhost in smoke-test + README + spec.
De alpine-GenericContainer miste dit (geen port-wait). Host-agent hiermee niet
meer nodig in de sandbox (blijft fallback).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…allback) (#44)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… nodig (#44)

Geverifieerd: Docker-default seccomp faalt op 'cannot clone: Operation not
permitted' (podman re-exec gebruikt clone(CLONE_NEW*)). seccomp=unconfined is
dus nodig, geen gold-plating. Spec: hardening-verfijning-sectie met het
tailored-seccomp-vervolgpad + waarom apparmor/systempaths nauwelijks te
versmallen zijn. De veiligheid zit in de scoping, niet in het wegpoetsen van de
inherente relaxaties.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
defaultAction=ALLOW (zodat podman's clone/unshare/mount/setns werken —
Docker-default brak hierop) + ERRNO-deny op de gevaarlijke kernel-escape-syscalls
die Docker-default ook blokkeert (module-load, kexec, reboot, iopl/ioperm,
swap, klok-zetten, bpf, perf_event_open, open_by_handle_at, acct, _sysctl,
vm86). Strikt veiliger dan unconfined zonder podman te breken. Override verwijst
naar seccomp/podman-sandbox.json (pad relatief t.o.v. compose-bestand).

Nog te verifieren op de host (recreate + smoke).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ smoke groen) (#44)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#44)

#44-DoD: afweging vastgelegd als ADR (docs/adr/0001-...): podman-in-docker als
voorkeur, host-agent als fallback, security-balans, goedkope hardening, C/D
uitgesteld. maven-mcp-agent.md verwijst nu naar het podman-alternatief en
documenteert de host-agent expliciet als fallback + de goedkope hardening
(dedicated least-priv user, projecten buiten gedeelde map, bind 127.0.0.1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oc-podman→podman (#44)

Niet langer een PoC maar een voorstel tot echt gebruik dat de Maven host-agent
beoogt te vervangen (PR-review + collega-tests; bij succes kan de host-agent weg,
mits objectief beter). Teksten ontdaan van PoC-framing in README, spec, ADR,
maven-mcp-agent, override- en script-comments. Map host-agents/maven/poc-podman
→ podman (+ seccomp-pad in override). smoke-test eindmelding "PoC GESLAAGD" →
"OK — Testcontainers werkt". Plan-doc blijft als historisch record.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
De host-agent is een DEKKINGS-fallback (hosts waar podman-in-docker nog niet kan
+ Mac/Win te verifieren), geen security-upgrade. De eerdere claim "voor wie de
outer-relaxaties niet wil" klopt niet: die relaxaties verbreden het
kernel-oppervlak van de container (escape vereist nog een exploit), terwijl de
host-agent code direct op de host draait — voor een op container-escape beduchte
gebruiker juist zwakker. ADR + maven-mcp-agent bijgewerkt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…44)

Issue-comment merkt terecht op: /dev/fuse is een (klein) kernel-risico en hoort
pas toegevoegd bij traagheid, niet als default. Tot nu toe was fuse-overlayfs +
/dev/fuse de default. Nu:

- Default storage = vfs (geverifieerd: single-uid + [storage.options.vfs]
  ignore_chown_errors=true, smoke groen, geen /dev/fuse).
- Configureerbaar via .env: PODMAN_STORAGE_DRIVER=vfs|overlay +
  PODMAN_FUSE_DEVICE=/dev/null|/dev/fuse. Entrypoint genereert storage.conf uit
  de driver; valt terug op vfs + waarschuwing als overlay gevraagd is maar
  /dev/fuse ontbreekt (footgun-guard).
- compose-override: /dev/fuse vervangen door ${PODMAN_FUSE_DEVICE:-/dev/null}
  (no-op default) + PODMAN_STORAGE_DRIVER env-passthrough.
- Dockerfile baked storage.conf → vfs; fuse-overlayfs blijft geïnstalleerd zodat
  opt-in geen rebuild vergt (inert zonder /dev/fuse).
- Spec/ADR/README/.env.sample bijgewerkt: vfs default, fuse opt-in + security-noot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e sourcet sdkman (#44)

- Dockerfile: inline #-comments uit de \-gecontinueerde case-RUN gehaald (werkte
  op buildkit, maar fragiel op classic builder) → naar comment-blok erboven.
- smoke-test.sh: sourcet nu zelf sdkman + faalt met duidelijke melding als mvn
  ontbreekt, zodat het standalone werkt (niet alleen via de README-wrapper).
  Standalone geverifieerd: smoke groen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ebreid (#44)

Security-review: de blocklist miste escape-relevante, cap-gated syscalls die
Docker-default wél blokkeert en die podman/JVM-Testcontainers niet nodig hebben.
Toegevoegd: userfaultfd, io_uring_{setup,enter,register}, NUMA
(mbind/set_mempolicy/migrate_pages/move_pages), process_vm_{readv,writev},
process_madvise, fanotify_init, kcmp, pidfd_getfd, en module-load-rest
(create_module/query_module/get_kernel_syms). ptrace bewust NIET geblokkeerd
(Docker-default laat het toe). Docs: vergelijkingstabel-rij geactualiseerd,
seccomp-omschrijving + ptrace-noot, SELinux label=disable in de security-balans.

Seccomp is create-time → uitgebreide blocklist nog op een recreate te bevestigen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…abel + .example (#44)

Drift gevonden in de spec "Artefacten"-tabel (stond buiten de ontwerp-disclaimer):
beschreef nog subuid baked + fuse-overlayfs storage + devices:[/dev/fuse]. Nu in
lijn met de werkelijke config (single-uid, vfs-default, /dev/net/tun +
${PODMAN_FUSE_DEVICE}). ADR/spec verwijzen nu naar compose.override.podman.yml.example
(met .example) i.p.v. de losse naam. Rename poc-podman→podman volledig schoon,
geen PoC-refs in living docs (alleen in het historische plan-doc), links resolven.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
compose.override.podman.yml.example → compose.override.podman.yml. Het bestand
wordt direct met -f meegegeven (geen kopieer-template), dus .example was
misleidend. Wordt niet auto-geladen (Compose auto-merget alleen compose.override.yml),
dus blijft opt-in. Refs in README/spec/ADR/plan/Dockerfile/.env.sample/entrypoint
bijgewerkt; header-noot "kopieer naar compose.override.yml" vervangen door
expliciete -f-instructie + waarschuwing tegen hernoemen. Linux-override blijft
.example (dat is wél een kopieer-template).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bevestigd op een Mac met Podman-machine (applehv → Fedora CoreOS):
rootless podman-in-podman draait en de Maven+Testcontainers-smoke-test
slaagt end-to-end.

- Nieuwe compose.override.podman-macos.yml: apparmor=unconfined (Fedora
  CoreOS heeft geen AppArmor/userns-hardening), label=disable voor SELinux,
  absoluut seccomp-pad via ${PWD} (podman leest het profiel client-side).
- Hernoem compose.override.podman.yml -> compose.override.podman-linux.yml
  en werk alle verwijzingen bij (README, setup-host.sh, entrypoint.sh, ADR,
  spec, plan).
- README: macOS-sectie met de drie afwijkingen (podman-compose i.p.v.
  podman compose, BUILDAH_FORMAT=docker, absoluut seccomp-pad), fallback-
  rijen voor de macOS-fouten, en per-setup matrix bijgewerkt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ent-opt-in (#44)

PODMAN_FUSE_DEVICE was redundant naast PODMAN_STORAGE_DRIVER. Compose kan een
devices-entry niet uit de waarde van een var afleiden (geen conditionals in
interpolatie), dus i.p.v. een tweede env-var is /dev/fuse nu een
uitgecommentarieerde regel in de overrides die je uncomment voor overlay.
PODMAN_STORAGE_DRIVER blijft de enige .env-knop; entrypoint waarschuwt + valt
terug op vfs als overlay gekozen is zonder het device (geen broken start).
Linux- + macOS-override, entrypoint, .env.sample, README en spec bijgewerkt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…44)

Nieuwe gids docs/kata-linux-maximale-isolatie.md: Kata Containers (eigen
guest-kernel per container) als de kernel-escape-laag op Linux-native — de plek
waar die grens by default ontbreekt. Inclusief: voorwaarden (KVM/nested virt),
eerlijke noot over Docker-Engine↔Kata-integratiefrictie (shim-v2 vs containerd/
nerdctl), interactie met de podman-hardening (host-AppArmor-profiel waarschijnlijk
overbodig onder Kata), en caveats. Mac/Windows: kernel-grens al aanwezig (VM via
Docker Desktop/Rancher/podman machine) → Kata niet nodig, met WSL2/projects-mount
kanttekeningen. Niet getest in deze omgeving; gemarkeerd als te verifiëren.
Gelinkt vanuit ADR (Optie D) + podman-README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Een niet-numerieke -p of --tunnel-port kwam pas veel later naar boven als een
ssh-fout over een bad forwarding specification, of als een mislukte connect in
de pre-flight.
…le weg

- Een RSA-sleutel onder 3072 bits werd met een geslaagd-melding weggeschreven en
  daarna door sshd geweigerd (RequiredRSASize). De enige aanwijzing was
  Permission denied (publickey), en de smoke-test wees naar de verkeerde
  oorzaak. De entrypoint weigert de sleutel nu vooraf, de README noemt de eis en
  de smoke-test noemt hem als diagnose.
- ADR 0001 kende de sshd niet, terwijl de code naar §2.3 verwijst en het
  isolatieverhaal daar hoort te staan. Nieuwe §2.3.4 voor de rootfase-start en
  een punt in §4.2 over het inbound-oppervlak dat dit toevoegt.
- Dezelfde onderbouwing stond drie tot vier keer uitgeschreven. De ADR is nu de
  plek; op de aanroeplocaties staat de regel met een verwijzing.
- Poortvalidatie las 08 als octaal en lekte een rauwe bash-fout voor de nette
  melding.
…evangen

De reden waarom authorized_keys in de gebruikersfase geschreven wordt was op drie
plekken onjuist opgeschreven: StrictModes accepteert root-eigendom van de
padcomponenten. De werkelijke reden is dat ~/.ssh van claude moet blijven, zodat
wie KEPLER_SSH_PUBKEY leeg laat het bestand zelf kan beheren op het volume.

- Poortvalidatie begrensde de lengte niet, waardoor een waarde boven 64 bits de
  rekenkundige vergelijking liet wrappen en alsnog doorging.
- RequiredRSASize 3072 staat in kepler.conf en werd hard herhaald in de
  entrypoint. De smoke-test toetst die directive nu, zodat de twee niet stil
  uiteen kunnen lopen.
- ADR 2.3.4 benoemt weer waarom de eigendomscontrole op de host-key bestaat.
- Een mislukte login stapte uit voor het blok dat de sshd-auth-log dumpt, terwijl
  daar met LogLevel VERBOSE juist de reden staat. De dump is nu een functie die
  ook voor de harde exits draait.
- De rechtencheck op authorized_keys eiste exact 600/700 en stond achter de
  logintest. Daardoor was hij onbereikbaar precies wanneer hij ertoe deed, en gaf
  hij vals rood op het zelfbeheer-pad dat de README zelf aanraadt. Nu op de
  invariant die sshd afdwingt: niet schrijfbaar voor groep of anderen.
- De root-fase geeft zijn uitkomst door via SSHD_STATUS. De gebruikersfase
  schreef anders een sleutel weg met een geslaagd-melding terwijl er geen sshd
  luisterde, en gaf bij een ongeldige ENABLE_SSHD advies over een var die wel
  gezet was.
- Een lege of onbruikbare host-key op het volume passeerde de controle; sshd
  startte dan niet en geen van de gemelde oorzaken klopte.
- ONGEWIJZIGD gelaten stond ook in de melding als er niets te bewaren viel.
- Smoke-test meldt het als de entrypoint de sleutel uit .env geweigerd heeft; de
  run was anders groen terwijl een oude sleutel van het volume het werk deed.
- De build noemt de ontbrekende directive bij naam in plaats van alleen een
  exit-code, en de CI-comment claimt niet meer dan een gecachte laag waarmaakt.
De vorige commit beweerde twee dingen die niet in de code stonden: de
gebruikersfase ging nog steeds op ENABLE_SSHD af in plaats van op de doorgegeven
SSHD_STATUS, en de conditionele keep-tekst bestond niet. Beide alsnog toegepast.
Daardoor schreef de gebruikersfase bij een mislukte sshd-start nog altijd een
sleutel weg met een INFO-regel, en las een gebruiker op een vers volume dat zijn
authorized_keys ONGEWIJZIGD was gelaten terwijl er niets stond.

Verder uit dezelfde ronde:
- De rechtencheck in de smoke-test liet test -O vallen en werd daarmee blind voor
  precies de regressie die hij bewaakte: een root-geschreven authorized_keys met
  mode 644 in een 755-directory kwam er groen doorheen. Eigendom staat er weer
  bij, naast de rechten-invariant.
- De zelfbeheer-waarschuwing controleert ook de home-directory; sshd loopt de
  hele keten na en weigert elk niveau dat voor anderen schrijfbaar is.
- dump_logs toont de reden als de container gestopt is, in plaats van die met
  een generieke tekst te vervangen.
- De sleutelcheck grept niet meer op de afwezigheid van een waarschuwing binnen
  --tail 200, maar positief op de INFO-regel in het volledige log, met dezelfde
  canary als de expect-no-sshd-modus. Dat dekt ook de weigerpaden die de naam van
  de variabele niet noemen, en is ongevoelig voor een stale waarschuwing.
- grep -q achter een pipe kan onder pipefail een treffer als misser rapporteren
  wanneer de producer SIGPIPE krijgt; omgezet naar een here-string.
…eren

- De sectie die controleert of de sleutel uit .env geaccepteerd is, gate-de op
  een host-shell-variabele. Het script leest .env niet, dus onder de
  gedocumenteerde aanroep was die var leeg en deed de hele sectie niets. Nu wordt
  de container-env gelezen — dezelfde waarde die de entrypoint bij de laatste
  start zag — en worden vingerafdrukken vergeleken. Dat is ook ongevoelig voor
  logrotatie, logdriver en een herstart.
- dump_logs verving de foutregel nog steeds door een eigen diagnose die onwaar
  kan zijn: een onbereikbare daemon of een verkeerde containernaam werd gemeld
  als container draait niet meer. Beide bronnen tonen nu hun eigen stderr.
- Sectie 0 duidde elke fout van ps als container draait niet; een daemon die
  niet draait of ontbrekende socket-rechten krijgen nu hun eigen melding.
- De failed-tak in entrypoint.sh was onbereikbaar en beweerde het tegendeel van
  wat de code doet. Weg, met de rationale erbij waarom failed als actief telt.
- Het schrijffaalpad claimte nog steeds een bestaande sleutel; gebruikt nu keep.
- De rechtencheck op de home-directory geldt voor beide paden in plaats van
  alleen het zelfbeheerpad, en de remediatie noemt chmod 755 op de home-dir.
- Twee tegengestelde defaults voor SSHD_STATUS in hetzelfde bestand opgeruimd.
…lde de sleutelvergelijking

- find geeft exit 1 zodra een van de opgegeven paden ontbreekt, ook als het voor
  de andere wel een treffer print. De rechtencheck stond achter die exit-code en
  zweeg daardoor juist op een vers volume, waar authorized_keys nog niet bestaat
  — een van de gevallen waarvoor hij bedoeld was.
- De vingerafdruk van authorized_keys werd met 2>&1 opgehaald. Een WARN-regel van
  de CLI (podman doet dat routinematig) belandt dan voor de vingerafdruk in de
  waarde, waarna de vergelijking vals-rood geeft met een stellige onjuiste
  diagnose. Stromen gescheiden via een tempbestand.
- Een mislukte exec bij het lezen van KEPLER_SSH_PUBKEY werd gerapporteerd als
  zelfbeheerde authorized_keys, waarna de sectie stil groen bleef. printenv geeft
  1 voor niet-gezet; alles daarboven is nu een fout met de stderr erbij.
- De vergelijking eiste gelijkheid terwijl de melding bevat zegt; bij meerdere
  sleutels in het bestand gaf dat vals-rood.
- OPEN_HTTPS viel bij een exec-fout terug op een verzonnen waarde waarop
  vervolgens een lek gemeld werd.
grep -q sluit af zodra hij matcht; de producent krijgt dan SIGPIPE en dat telt
onder pipefail als mislukt. Bij veel sleutels in authorized_keys gaf juist een
vroege treffer daardoor vals-rood (rc 141). De omsluitende spaties in het patroon
voorkomen dat een langere vingerafdruk met dezelfde prefix meematcht.
- OPEN_HTTPS: niet gezet en onleesbaar vielen samen op leeg. Compose injecteert de
  var niet als hij ontbreekt en init-firewall valt dan terug op false, dus een
  bereikbare example.com is in dat geval wel degelijk een lek — dat werd
  afgezwakt tot niet vast te stellen. De drie uitkomsten staan nu uit elkaar, en
  bij een CLI-fout komt de reden mee.
- De KEPLER_SSH_PUBKEY-check leunde op de aanname dat alleen exit-codes boven 1
  een CLI-fout zijn. Welke code een implementatie voor een daemon-fout kiest
  verschilt; lege stderr is het betrouwbare onderscheid met niet-gezet.
- Een falende sshd -T gooide zijn eigen foutregel weg, terwijl daar meestal
  Bad configuration option in staat — precies waar de entrypoint naar verwijst.
- mktemp overal geguard; zonder schrijfbare tempdir eindigde de run anders vlak
  voor de samenvatting.
De OpenSSH-default zoekt ook .ssh/authorized_keys2. Dat bestand werd nooit
gevalideerd en ook niet overschreven als KEPLER_SSH_PUBKEY gezet is, terwijl het
wel meetelt bij de autorisatie. Nu expliciet op .ssh/authorized_keys, geasserteerd
in de build en in de smoke-test.
@ericwout-overheid
ericwout-overheid changed the base branch from feat/maven-podman-poc to main August 6, 2026 06:51
De bestaande Trivy-runs zijn scan-type fs en zien daarmee geen enkel
apt-pakket. openssh-server komt ongepind uit apt en valt buiten Dependabot, dus
er was geen enkel signaal op een kwetsbare sshd. De job die de variant toch al
bouwt scant hem nu; ignore-unfixed houdt het actionable.
feat/maven-podman-poc is squash-gemerged in main (f6558ee), waardoor GitHub PR 92
omhing naar main en de merge-base terugviel naar van voor het podman-werk. Elk
gedeeld bestand botste daardoor, terwijl de inhoud grotendeels dezelfde is.

Onze kant behouden voor alle Kepler-toevoegingen; van main overgenomen wat daar
nieuwer is: Node.js v24.18.1, de postgres-bump in de sample-pom, en de
tools-tabel met Node.js 24 LTS en bredere kolommen. De openssh-server-vermelding
staat weer in de Netwerk-rij.
De scan blokkeerde de PR op npm-modules die met de Node.js-tarball meekomen
(tar, brace-expansion, ip-address, undici). Die zitten net zo goed in de
default-image, en die wordt hier niet gescand — alleen deze PR erop laten vallen
is willekeurig. De job bestaat om apt-pakketten te dekken, want openssh-server
komt ongepind uit apt en valt buiten Dependabot.
ssh-keygen schrijft zowel de private als de publieke sleutel, maar de guard
toetste het type alleen op de private. Op het .pub-pad werd alleen een symlink
afgevangen. Een fifo daar laat ssh-keygen blokkeren op open(), in de root-fase
en vóór de privilege-drop, zonder melding en zonder exit — restart-beleid grijpt
dan niet in, want het proces eindigt nooit.

/home/claude is van claude, en schrijfrecht op de parent is genoeg om de hele
.ssh-host-directory te vervangen, dus de ingesloten partij kan dat pad zetten.
…ttrekken

sshd werd gestart zonder env-scrub en erfde dus de volledige container-env; zijn
pre-auth-child erft die over de fork heen. Een pre-auth-lek in OpenSSH leest de
sleutel daarmee uit /proc/self/environ, zonder authenticatie en zonder verdere
escalatie. Voor een sessie maakt het niets uit: sshd bouwt daar toch een verse
omgeving op.

De README zette dat sshd de env reset onder 'niet omzeild'. Dat is waar over
sshd, maar het leest als 'de sleutel is buiten bereik' en dat klopt niet: PID 1
draait na de drop als claude met die variabele, en een sessie is dezelfde uid.

Ook toegevoegd bij de beperkingen: claude kan de host-identiteit laten roteren
door .ssh-host te hernoemen. Hij kiest de nieuwe sleutel niet, maar dwingt wel
bij elke start een known_hosts-mismatch af.
Het README-bewijs voor de leesbare API-sleutel wees naar /proc/1/environ, maar
compose.yml zet init: true — PID 1 is tini als root, en dat pad is voor claude
juist niet leesbaar. Wie de claim natrok kreeg Permission denied en zou het
omgekeerde concluderen. De conclusie zelf klopt en is breder: elk proces van
claude dat de container-env draagt, levert de sleutel.

De sshd -T-asserties vergeleken hoofdlettergevoelig met lowercase sleutels.
Upstream OpenSSH schrijft die dump in mixed case; bij een base-image-bump zou de
build falen met de misleidende melding dat de drop-in niet toegepast is.

De env-scrub is een denylist van één variabele; een sync-comment koppelt hem aan
de secrets in compose.yml.
De image zet /home/claude/.local/bin vooraan in PATH voor de claude-CLI. Dat pad
ligt op het claude-home volume en is van claude, terwijl entrypoint-root.sh en
init-firewall.sh als root draaien en hun binaries op naam aanroepen — iptables,
setpriv, ssh-keygen, install, stat. Wie in de container als claude kan schrijven,
bepaalt daarmee welke van die commando's root uitvoert bij de eerstvolgende
start; het restart-beleid en een gewone docker restart zijn genoeg om dat te
laten gebeuren, vóór de privilege-drop en met NET_ADMIN en NET_RAW.

De root-fase draait nu met een vaste PATH naar de systeemdirectories. De
gebruikersfase krijgt de PATH van de image terug vlak vóór de drop, want daar is
/home/claude/.local/bin juist nodig.
Het formaat van de sleutelnamen in 'sshd -T' is niet toegezegd; vandaag is het
lowercase. Wijzigt dat ooit, dan faalt de build met een melding die naar de
drop-in wijst in plaats van naar de dump.
# Conflicts:
#	claude-sandbox/entrypoint-root.sh
De directive-lus gebruikte grep -qix, dus de waarde werd als reguliere expressie
gelezen. Bij 'permitopen localhost:* 127.0.0.1:* [::1]:*' is [::1] een
bracket-expressie en :* nul of meer dubbele punten, waardoor die regel zichzelf
niet kan matchen en de sectie per definitie rood is. Een assertie die altijd
faalt leert lezers de sectie te negeren.
)

* fix(kepler): auth-events naar de containerlog i.p.v. een eigen bestand

/var/log/sshd.log stond op de container-laag: een recreate wiste hem en er was
geen rotatie. Met -D -e gaan de auth-events naar de containerlog, waar de
rotatie van de logdriver geldt en de operator toch al kijkt.

-D betekent dat sshd niet daemoniseert, dus hij gaat met & naar de achtergrond.
De exit-code zegt daarmee niets meer over succes; de bestaande wachtlus op
/run/sshd.pid blijft het signaal, aangevuld met een kill -0 op het proces.

Smoke-test toetst nu op sshd-regels in de containerlog in plaats van op de
rechten van een bestand dat niet meer bestaat.

* fix(kepler): auth-log naar het volume in plaats van naar de containerlog

De containerlog was de verkeerde bestemming, om twee redenen die bij het
reviewen naar boven kwamen.

De aanleiding klopte niet: een containerlog bestaat per container, dus een
recreate wist hem net zo goed als een bestand op de container-laag. Het probleem
dat deze wijziging moest oplossen bleef dus bestaan.

En het maakte het spoor onbetrouwbaar. Na de privilege-drop schrijft claude zelf
naar diezelfde stroom, dus een verzonnen Accepted publickey-regel is in docker
logs niet te onderscheiden van een echte, en oudere records zijn weg te duwen.
De gelogde partij kon daarmee zijn eigen auth-spoor vervalsen.

De log staat nu op het claude-home volume als 640 root:claude: overleeft een
recreate, en alleen root schrijft. De smoke-test toetst het echte auth-event
(Accepted publickey na de login uit sectie 3) in plaats van de startbanner, plus
de rechten van het bestand.

De README is eerlijk over wat er niet gedekt is: geen rotatie, en een log die de
gelogde partij kan lezen is geen bewijsmateriaal zolang het het enige exemplaar
in de sandbox is.

* fix(kepler): auth-assertie ná de login, symlink-guard op het logbestand

De assertie op Accepted publickey stond in sectie 1b, terwijl de eerste login pas
in sectie 3 gebeurt. Op een vers volume gaf dat een vals-rood met een misleidende
diagnose — en dat is het standaardpad, want ENABLE_SSHD aanzetten vereist een
volume-recreate. Op een hergebruikt volume was het omgekeerde erger: het bestand
overleeft een recreate en wordt nooit getrunceerd, dus één geslaagde run hield de
check voorgoed groen, ook als de logging daarna gesloopt werd.

De assertie staat nu ná de login en telt alleen de regels die sinds een offset
van vóór die login zijn bijgekomen.

prepare_auth_log had een type-guard op de directory maar niet op het bestand.
[[ -f ]] dereferencet, dus een symlink op die plek liet het auth-spoor ergens
anders belanden en zette root een bestand naar keuze op root:claude 640. claude
bezit /home/claude en kan die directory aanmaken voordat sshd voor het eerst
start. Nu dezelfde guard als bij de host-key.

De faalmelding noemt het pad en de waarschijnlijke oorzaken, en de README claimt
niet langer dat claude het spoor niet kan wissen: aanpassen en verwijderen van
regels kan hij niet, de directory wegschuiven wel.

* fix(kepler): offsetmeting mag niet stil op nul terugvallen

Een mislukte meting viel op 0 terug, en 0 betekent tel het hele bestand.
Daarmee zou een Accepted publickey van een vorige run de assertie voorgoed
groen houden — precies het gat dat de offset moest dichten. Alleen bestand
bestaat nog niet levert nu 0 op; een onleesbare meting is een bevinding.

De symlink-guard vermeldt dat hij geen hardlink dekt.

* fix(kepler): logdirectory meetoetsen, faaldiagnose naar het juiste pad

De smoke-test toetste alleen het bestand. De 750 op de directory is juist wat
claude belet regels te verwijderen, want het bestand staat in zijn eigen home;
zakt die ooit terug naar 770 of naar eigenaar claude, dan bleef de check groen
terwijl de premisse van deze PR weg is.

De oorzakenlijst bij een mislukte start verwees naar de containerlog, een
restant van de tussenliggende opzet. sshd schrijft naar SSHD_LOG en daar staat
de reden.

Het comment claimde dat het spoor niet te verdringen is; de README zegt terecht
dat de directory wel weg te schuiven is.

* fix(kepler): auth-spoor buiten bereik van claude en met een tijdstempel

Een pad in /home/claude is root-eigen te maken, maar de ouder is van claude:
hij kan de directory hernoemen en er een eigen sshd.log voor in de plaats
zetten. sshd schrijft dan door op de open fd, terwijl wie het
gedocumenteerde pad leest — inclusief de smoke-test — de vervalsing ziet.
Een spoor wegschuiven valt op, een spoor vervangen niet. Het bestand staat
nu op een eigen volume onder /var/log, waar alleen root de directory kan
hernoemen.

OpenSSH schrijft achter -E en -e het formaat "%s%s%.*s\\r\\n": geen datum, geen
tijd, geen pid. Een auth-spoor zonder tijd beantwoordt niet wanneer iemand
binnenkwam. sshd schrijft daarom naar een fifo en een root-leesluis zet de
tijd ervoor; valt die luis weg, dan blokkeert sshd op een volle fifo en is
er geen login zonder spoor.

* fix(kepler): CR van OpenSSH strippen in het auth-spoor

* fix(kepler): smoke-test en README mee naar het nieuwe logpad

De rechten-assertie toetst nu ook de ouder van de logdirectory: is die van
claude, dan kan hij het hele pad omleggen en leest iedereen die het
opvraagt zijn versie, terwijl stat op directory en bestand groen blijft.

De login-assertie eist een ISO-8601-tijdstempel voor de regel. Valt de
leesluis weg, dan komt de kale OpenSSH-regel in het bestand en zou een
grep op 'Accepted publickey' groen blijven zonder dat er nog te herleiden
is wanneer de login plaatsvond.

* docs(kepler): het aparte volume voor het auth-spoor benoemen

* fix(kepler): fail-closed-claim rechtzetten, down -v-gedrag benoemen

Het comment en de README beweerden dat een weggevallen leesluis de fifo laat
vollopen en sshd blokkeert. Dat gebeurt niet: schrijven naar een fifo zonder
lezer geeft EPIPE, sshd zet SIGPIPE op SIG_IGN en gooit de returnwaarde van
write() weg, dus regels van de daemon verdwijnen geruisloos. Nieuwe verbindingen
heropenen het logpad en blokkeren wel, vóór authenticatie — SSH valt dan stil
zonder melding, en de ingesloten partij kan dat uitlokken door het volume vol te
schrijven. Dat staat nu bij de beperkingen in plaats van als garantie.

claude-home is external en sshd-log niet, dus docker compose down -v wist het
auditspoor en laat de data staan. De README benadrukte alleen de andere kant.

Verder: /run is onder Docker geen tmpfs, en een laatste regel zonder afsluitende
newline viel uit de leesluis weg.

* fix(kepler): API-sleutel uit de omgeving van sshd

* docs(kepler): API-sleutel-bullet en de trigger voor een dode leesluis rechtzetten

De bullet zei dat de sleutel een sshd-sessie niet bereikt. Dat is waar over
sshd's env-opbouw, maar elk proces van claude draagt de variabele en een sessie
is dezelfde uid.

De beschreven trigger klopte niet: claude heeft geen schrijfrecht op de
logdirectory en kan het volume niet rechtstreeks vullen. Wat wel geldt en
scherper is: één mislukte schrijfactie beëindigt de luis definitief, want hij
erft errexit.

* docs(kepler): preciezer waar de API-sleutel wel en niet staat

Niet elk proces van claude draagt de variabele: de SSH-sessie juist niet, en dat
is precies waarom een aanvaller een ander proces moet uitlezen.

* fix(kepler): auth-spoor via syslogd in plaats van een eigen fifo

sshd schreef met -E naar een fifo waar een bash-leesluis de tijdstempel bijzette.
Dat leverde een tijd op, maar met een prijs: één mislukte schrijfactie doodde de
luis definitief, waarna regels van de daemon geruisloos verdwenen en nieuwe
verbindingen blokkeerden op het openen van de fifo — vóór authenticatie, dus SSH
viel stil zonder melding. De keten kon dat bovendien niet afkeuren, want de
functie eindigde op een achtergrondjob en gaf altijd 0 terug.

busybox syslogd doet waar hij voor bestaat: hij zet de tijd erbij en roteert op
grootte, en als hij wegvalt blokkeert hij sshd niet, want syslog() schrijft naar
een socket en is best-effort. De start controleert dat /dev/log er is voordat
sshd begint, anders zouden de eerste regels alsnog verdwijnen.

De padbescherming blijft ongewijzigd: eigen volume onder een root-eigen
/var/log, 640 root:claude in een 750 root:claude-directory.

Twee dingen die deze opzet niet oplost en die nu bij de beperkingen staan: een
weggevallen syslogd is stil, en het syslog-formaat draagt geen jaartal.

* fix(kepler): auth-spoor rechtstreeks naar het bestand, zonder logdaemon

De syslog-variant liep op twee dingen vast die los staan van de tijdstempel.
busybox syslogd leest zonder -f zijn eigen /etc/syslog.conf, en dat bestand komt
mee met het pakket; matcht daar een regel, dan schrijft hij daarheen en slaat de
-O-bestemming over, waarmee het spoor op de container-laag belandt in plaats van
op het volume. En de socket wordt bij het binden op 0666 gezet, dus elk lokaal
proces kan er regels in schrijven met een zelfgekozen tijdstempel — precies wat
deze opzet moet uitsluiten.

Beide zijn te ondervangen, maar niet zonder het rechtenmodel van syslog te
verbouwen, en dat hoort niet in een wijziging over de logbestemming. sshd
schrijft daarom rechtstreeks met -E naar het root-eigen bestand: dat houdt de
invariant die deze wijziging beoogt, zonder daemon, socket of routeringsconfig.

De prijs is dat de regels geen tijd dragen. Dat staat nu expliciet bij de
beperkingen, met de reden erbij: OpenSSH laat de tijd aan syslog over.

De smoke-test toetst nu de key-fingerprint in plaats van een tijdstempel — dat
is de enige identificatie die het spoor draagt, want het bron-IP is door NAT
altijd de gateway.

* fix(kepler): LogLevel bewaken waar dat kan, ouder-assertie op de mode

De controle op de key-fingerprint pretendeerde te bewaken dat LogLevel VERBOSE
in de drop-in staat. Dat kan hij niet: OpenSSH verhoogt het niveau naar INFO
zodra de authenticatie slaagt en plakt de fingerprint hoe dan ook aan die regel,
dus de tak vuurt nooit voor het scenario in zijn eigen foutmelding. De directive
wordt nu getoetst waar dat wel kan — in de build tegen sshd -T, en in de sectie
van de smoke-test die de effectieve config nagaat. De fingerprint-controle
blijft, met een melding die zegt wat hij echt aantoont.

De ouder van de logdirectory werd op eigenaar getoetst terwijl hernoemen aan de
mode hangt: een groep- of other-schrijfbare /var/log laat het hele pad omleggen
terwijl stat op directory en bestand klopt.

Verder aan de beperkingen toegevoegd: een vol filesystem legt het spoor stil
zonder dat sshd dat merkt, en de regels eindigen op CRLF. De ankering van
regels via de containerlog gold alleen binnen één containerleven, terwijl het
spoor juist een recreate overleeft.

* fix(kepler): mount meetoetsen, claims over logniveau en /run rechtzetten

De rechten-assertie toetste alles behalve de eigenschap waar deze wijziging om
draait: dat het spoor op een eigen mount staat. Zonder de volume-regel maakt de
entrypoint dezelfde directory met dezelfde rechten aan op de container-laag en
blijft de hele sectie groen, terwijl het spoor geen recreate meer overleeft.

Wat LogLevel VERBOSE toevoegt was te ruim opgeschreven: geweigerde gebruikers en
herhaalde mislukkingen staan al op het standaardniveau, en afgebroken
verbindingen loggen als fout. Wat er echt bijkomt is de eerste mislukte poging
per verbinding van een toegelaten gebruiker, plus de key-probe.

De fingerprint-controle is geen guard op de drop-in maar op het formaat; de
melding zei het eerste. Het comment bij de ouder-assertie ontkende de helft van
wat de code eronder doet — mode en eigenaar tellen allebei.

De claim dat /run onder podman een tmpfs is en onder Docker niet, is niet
nagemeten en is vervangen door wat wel vaststaat.
* experiment(kepler): sshd als claude op poort 2222, geen root-daemon

Poort 2222 vereist geen root, dus sshd start na de privilege-drop als claude. Er
draait daarmee geen root-daemon in de container: een pre-auth-lek in OpenSSH
levert claude op in plaats van container-root, en de setpriv-bounding-set is niet
langer nodig omdat het proces sowieso geen capabilities heeft.

De prijs is dat OpenSSHs eigen privilege separation vervalt; die vereist root om
het pre-auth-proces af te splitsen.

De host-key blijft van root, nu 640 root:claude: sshd moet hem kunnen lezen maar
de ingesloten partij mag hem niet vervangen. Pidfile verhuist mee naar een pad
dat claude kan schrijven.

Smoke-test keert de root-assertie om en toetst een lege effectieve
capability-set; de tunneltest wijst naar de nieuwe poort.

* fix(kepler): UsePAM uit, pidfile naar /run, eerlijk over wat de opzet kost

UsePAM stond nog op de Debian-default yes. sshd_config(5) stelt dat je sshd met
UsePAM aan niet als niet-root kunt draaien: de sessiemodules verwachten root. Dat
is de dragende aanname van deze PR, dus de drop-in zet hem nu expliciet uit en de
build asserteert dat.

De pidfile-directory was 750 root:claude. Groep krijgt daarmee r-x, geen
schrijfrecht, terwijl mijn eigen comment eronder zei dat sshd er zijn pidfile
moest schrijven. Gevolg: sshd luistert, de wachtlus loopt vol, en de operator
leest starten mislukt. De pidfile staat nu in /run/sshd-claude, dat claude bezit;
.ssh-host blijft 700 root:root, want schrijfrecht daar zou betekenen dat de
host-key te unlinken en vervangen is. De opruiming bij een mislukte start is
terug.

Smoke-test: de poortbinding-check keek nog naar container-poort 22, die niet meer
bestaat — de belangrijkste security-assertie faalde dus altijd. Ook de assertie
op het inmiddels dode /var/log/sshd.log is weg, de capability-check toetst weer
permitted naast effective, en een ontbrekend sshd-proces krijgt een eigen melding
in plaats van draait hij toch als root.

README en ADR benoemen nu de tweede prijs van deze opzet: claude kan de host-key
lezen. Dat is genoeg om sshd te doden, zelf op dezelfde poort te binden met
diezelfde sleutel en zonder known_hosts-mismatch door te gaan — waarmee ook
PermitOpen en AllowTcpForwarding onder controle staan van de partij die ze zouden
moeten beperken. Dode chgrp/chmod op de wegwerpsleutel in de Dockerfile zijn weg.

* fix(kepler): host-key-directory doorloopbaar houden voor claude

Ik had de directory bij het oplossen van het pidfile-probleem teruggezet naar
700 root:root. Zonder x-bit voor de groep kan claude er niet doorheen en dus de
host-key niet openen, ongeacht de 640 op het bestand zelf — sshd zou niet
starten.

750 root:claude is de juiste stand: doorlopen en lezen mag, aanmaken en unlinken
niet. Dat laatste is waarom het geen 770 wordt; de pidfile staat daarom in /run.

* fix(kepler): sleutel vóór de sshd-start, rechten kloppend gedocumenteerd

De README beschreef de host-key-directory als 700 root:root. Dat is precies de
stand waarin sshd als claude niet start — nagemeten op debian 13 met OpenSSH
10.0p2: no hostkeys available, geen pidfile. De code zet 750 root:claude; de
README zei het tegenovergestelde en nodigde uit om terug te hardenen naar een
opzet die stil breekt.

authorized_keys wordt nu geschreven vóór sshd start. Beide stappen staan in
hetzelfde script, dus het venster waarin sshd luistert zonder dat er een sleutel
staat is te vermijden in plaats van te documenteren.

Verder:
- De smoke-test toetste eigenaar en mode maar niet de groep, terwijl de groep
  juist het dragende mechanisme is: 750 root:root zou geslaagd zijn en sshd
  alsnog breken. Nu %U %G %a.
- usepam no staat in de runtime-directive-lijst. Met UsePAM aan bindt sshd de
  poort gewoon en breekt hij pas bij de sessie-opzet, dus dit is de enige plek
  waar zo n regressie zichtbaar wordt.
- De pass-tekst zei nog 0700 waar de assertie 750 toetst.
- Vier verwijzingen naar de root-fase-opzet rechtgezet, en het comment over /run
  als verse tmpfs: dat geldt onder podman, niet onder Docker — de rm in
  entrypoint.sh doet het werk.
- De diagnose bij een mislukte start noemt ook de directory-rechten.
- ADR-sectie staat niet langer onder de rootfase, en de PR-coördinatie is uit de
  README gehaald: die beschrijft de eindtoestand.

* fix(kepler): geslaagde start meldde ten onrechte dat sshd niet luistert

Door het verplaatsen van het authorized_keys-blok naar vóór de sshd-start is de
status op dat punt altijd ready; running wordt pas verderop gezet. De INFO-tak
was daarmee onbereikbaar en elke geslaagde start printte een waarschuwing die
verwees naar een eerdere melding die niet bestaat. Het onderscheid met een echt
mislukte start was daarmee ook weg.

Verder:
- SSHD_PORT=2222 in de kepler-override, gelijk aan de Port-directive. Zonder dat
  beschermt de firewallregel uit #101 na samenvoegen een dode poort terwijl 2222
  open staat voor het hele bridge-subnet.
- De build eist precies één port-regel: Port is cumulatief, dus een tweede regel
  zou sshd op twee poorten laten luisteren terwijl beide controles groen blijven.
- Er is weer een assertie dat een geslaagde login gelogd wordt, met een offset
  zodat een event van een vorige start niet meetelt. Die was met het oude
  logbestand verdwenen, terwijl dat juist de reden voor -e is.
- Het comment over het venster claimt niet meer dat er geen pad is waarop sshd
  zonder sleutel luistert; dat pad bestaat, het is alleen blijvend in plaats van
  een venster.
- ADR noemt de derde prijs: het auth-spoor wordt vervalsbaar, want sshd schrijft
  als claude naar dezelfde stroom als claude zelf.

* fix(kepler): offset-guard kon niet vuren, dode statuswaarde opgeruimd

De guard rond de offsetmeting hing aan de exitcode van een pipeline, en die is
die van wc — altijd 0. De fail-tak en de lege-offset-afhandeling waren daarmee
dood, precies de klasse fout die de vorige commit in de INFO-tak repareerde. De
logs-aanroep staat nu buiten de pipe.

running is op dit punt onbereikbaar: de root-fase geeft alleen disabled,
absent, invalid, failed of ready door, en running wordt pas verderop gezet. Uit
de case en de conditie gehaald zodat er geen tak overblijft die niet kan vuren.

Twee ADR-verwijzingen stonden nog op 2.3.4, en het comment in de override
verwees naar een PR-nummer in plaats van naar het script dat het gedrag levert.

* fix(kepler): tail-tak weghalen die niet kon vuren

tail op een herestring faalt niet, dus de fail-tak eromheen was onbereikbaar.

* fix(kepler): dode statustoekenningen weg, comments gelijk aan de branch

SSHD_STATUS wordt na de start nergens meer gelezen; de twee toekenningen in dat
blok waren dode stores.

Het comment in de override beschreef een firewallregel die op deze branch niet
bestaat, terwijl README en ADR in dezelfde branch het tegenovergestelde zeggen.
De variabele stuurt hier alleen de poortkeuze.

UsePAM no ontbrak in de hardening-opsomming, terwijl die directive de
voorwaarde is voor de hele niet-root-opzet en de smoke-test hem bewaakt.

* fix(kepler): prijs van de niet-root-opzet volledig opschrijven

De prijs van het wegvallen van privilege separation stond als "de scheiding
tussen pre- en post-auth vervalt". Concreet betekent het dat pre-auth-code
ongechroot als claude draait, met de host-bindmount, de login-credentials
en de container-env binnen bereik, in plaats van in een lege chroot als de
user sshd. De ruil is tweezijdig en de verliesrichting stond er niet.

Het auth-spoor staat niet alleen onder controle van sshd zelf: elk proces
van claude kan via /proc/<pid>/fd/1 in dezelfde stroom schrijven. Het ADR
noemde beide restrisico's niet, en claimde bij de host-key een controle die
alleen rotatie afdekt, geen geheimhouding.

rm -f op het pidfile faalt als daar een directory staat, en claude mag in
die tmpfs-loze directory schrijven; met errexit werd dat een herstartlus.

* docs(kepler): claims gelijktrekken met wat de code waarmaakt

De build-guard op meerdere Port-regels motiveerde zich met een firewallregel die
op deze branch niet bestaat. De guard blijft terecht: Port is cumulatief, dus
een tweede regel laat sshd ook op de niet-bedoelde poort luisteren.

De pidfile-route is niet de enige faalroute die de container meeneemt: een fifo
op het publieke host-key-pad blokkeert de root-fase net zo goed.

De host-key-garantie zat toegeschreven aan de directory-mode. Wie schrijfrecht
op /home/claude heeft kan de hele directory hernoemen; wat dat afvangt is de
controle op type en eigenaar bij elke start.

Het comment over de taakverdeling tussen de fasen beschreef nog de opzet waarin
de root-fase sshd zelf startte.

* fix(kepler): API-sleutel uit sshd, bounding set weer bewaakt, gaten benoemd

De pre-auth-code draait hier ongechroot als claude; zonder env-scrub staat de
API-sleutel in de omgeving van precies dat proces.

De capability-assertie toetste alleen permitted en effective. In de root-variant
verkleint setpriv de bounding set van sshd; hier gebeurt dat niet, en sinds de
assertie CapBnd losliet bewaakte niets meer wat sshd daaraan meekrijgt. Hij
wordt nu vergeleken met die van PID 1, zodat een verruiming opvalt zonder een
vaste waarde vast te leggen.

Twee dingen die in de beperkingen ontbraken: de root-variant weigert sshd te
starten als het auth-logbestand niet aan te maken is, en die eis bestaat hier
niet. En het overnemen van de host-identiteit reikt verder dan de eigen
container: in een zelf opgezette sshd staat AllowAgentForwarding aan, dus met
ForwardAgent aan de andere kant komt de ingesloten partij bij de SSH-agent op de
host.

* fix(kepler): assertie weghalen die alleen op hardening kan vuren

De CapBnd-vergelijking met PID 1 kon niet falen zoals bedoeld: een bounding set
kan niet groeien, hij wordt bij fork geërfd en blijft over execve staan, en sshd
start hier als plain child zonder setpriv ertussen. De enige manier om hem te
laten afwijken is sshd juist verkleinen — precies de hardening die de
root-variant heeft. Een guard die de verbetering afstraft is erger dan geen
guard. De controle op permitted en effective blijft; die vangt wel iets.

De API-sleutel-bullet zei dat de sleutel een sshd-sessie niet bereikt. In deze
opzet draait de pre-auth-code ongechroot als claude, dus de env-scrub verplaatst
het gat één /proc-lezing verderop in plaats van het te sluiten.

* docs(kepler): premisse bij de capability-assertie kloppend maken

Een bounding set kan wel degelijk groeien, namelijk in een nieuwe
user-namespace — en dat is precies wat rootless podman in deze image doet. Voor
sshd geldt het niet, want die unshared er geen; de conclusie blijft dus staan,
de onderbouwing niet.

* docs(adr): niet-root sshd uitgewerkt en verworpen, implementatie eruit

Deze branch bevatte de uitwerking van een sshd die ná de privilege-drop als
claude op poort 2222 draait, om het punt 'de daemon draait als root' uit ADR
0001 par. 4.2 te adresseren. Bij review viel de ruil de verkeerde kant op:
OpenSSH splitst zijn pre-auth-proces alleen af als het als root start, dus
zonder root komt pre-auth-code direct uit als claude — met de host-bindmount en
de credentials binnen bereik, in plaats van in een lege chroot. In een sandbox
die juist claude moet insluiten weegt dat zwaarder dan geen root-daemon hebben.

De zorg achter het oorspronkelijke punt was bovendien al afgedekt: de bounding
set van de daemon is verkleind, dus een lek levert geen container-root op die de
firewall kan flushen.

Wat overblijft is de afweging zelf, vastgelegd bij de verworpen alternatieven,
zodat navolgbaar blijft waarom de daemon als root draait.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants