diff --git a/README.md b/README.md index 7e1d6c7..c497477 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ claude-docker ~/repo -- --resume # any claude flag after -- | Flag | Effect | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--aws` | Mount `~/.aws/config` and `~/.aws/sso/` read-only and forward `AWS_PROFILE` / `AWS_REGION` / `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`. `~/.aws/credentials` (long-lived keys) and `~/.aws/cli/cache/` are **not** mounted. | +| `--aws` | Mount `~/.aws/config` and `~/.aws/sso/` read-only and forward `AWS_PROFILE` / `AWS_REGION` / `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`. `~/.aws/credentials` (long-lived keys) and `~/.aws/cli/cache/` are **not** mounted. Without the flag, `/root/.aws/` is hidden by a tmpfs overlay; with it, the overlay narrows to `/root/.aws/cli/cache/` so the STS credentials the CLI derives in-session don't persist on the shared volume. | | `--gh` | Starts a per-session **auth proxy sidecar** that holds the GitHub token — the agent container never sees it. Token discovery is unchanged (`GH_TOKEN` / `GITHUB_TOKEN`, else host `gh auth token`, else a silent skip with no sidecar and legacy no-token behavior). When a token is found, the agent container gets a placeholder `GH_TOKEN=claude-docker-proxy`, GitHub traffic is redirected to the sidecar, and the real `Authorization` header is injected in transit. `/root/.config/gh` stays masked while the sidecar is active. See [GitHub auth proxy](#github-auth-proxy). | | `--gh-direct` | Legacy escape hatch: same token discovery as `--gh`, but forwards the real token straight into the agent container as `GH_TOKEN` — no sidecar. For custom-hostname GitHub (Enterprise **Server**, `*.ghe.com`) and hosts that can't run the sidecar. Mutually exclusive with `--gh` (combining both is a startup error) and shown as its own `gh-direct` statusline tag. Unmasks in-container `gh auth login` state, same as pre-proxy `--gh`. | | `--glab` | Mount the platform-appropriate `glab-cli` config dir read-only (macOS: `~/Library/Application Support/glab-cli`, Linux: `~/.config/glab-cli`) and forward `GITLAB_TOKEN`. Unmasks in-container `glab auth login` state — without the flag, `/root/.config/glab-cli/` is hidden by a tmpfs overlay. | diff --git a/openspec/changes/archive/2026-09-08-mask-aws-container-state/.openspec.yaml b/openspec/changes/archive/2026-09-08-mask-aws-container-state/.openspec.yaml new file mode 100644 index 0000000..7a8e2be --- /dev/null +++ b/openspec/changes/archive/2026-09-08-mask-aws-container-state/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-08 diff --git a/openspec/changes/archive/2026-09-08-mask-aws-container-state/proposal.md b/openspec/changes/archive/2026-09-08-mask-aws-container-state/proposal.md new file mode 100644 index 0000000..a8dd67f --- /dev/null +++ b/openspec/changes/archive/2026-09-08-mask-aws-container-state/proposal.md @@ -0,0 +1,127 @@ +## Why + +`run.sh` already treats the AWS CLI's credential cache as too sensitive to bring +in from the host. The comment at `run.sh:394-395` says so outright — the scoped +`--aws` mount excludes `~/.aws/credentials` (long-lived access keys) and +`~/.aws/cli/cache` (cached assume-role STS) — and the spec restates it as an +absolute: "`~/.aws/credentials` and `~/.aws/cli/cache/` SHALL NEVER be mounted, +even under `--aws`." + +The container's *own* `/root/.aws/` then gets none of that care. It sits on +`claude-code-root`, which is mounted read-write and shared by every session, and +no tmpfs mask covers it. The masking requirement enumerates `/root/.config/gh`, +`/root/.config/glab-cli` and `/root/.terraform.d`; AWS is absent from it, and +`grep -n -- '--tmpfs' run.sh` returns those three paths and nothing else. So the +STS cache an `--aws` session writes persists after that session exits and is +readable by a later session that opted into nothing — the exact boundary the +opt-in model exists to enforce. + +Two scenarios in this capability are consequently false as shipped: + +- `external-cli-tools/spec.md:53` — with no flags, "`/root/.aws/` does not exist + inside the container". It exists, and after any `--aws` session it is + populated. +- `external-cli-tools/spec.md:66` — under `--aws`, "writes to `/root/.aws/` from + inside the container fail with EROFS". The directory is writable. + +The structural cause is that the spec asks for an outcome in one requirement +that no requirement provides a mechanism for. `Credentials opt-in` states the +desired end state for `/root/.aws/`; `In-container gh login persists only under +--gh` is where masking is actually specified, and it was written for the three +CLIs that have an in-container `auth login` flow, so AWS was never added to it. + +The EROFS claim appears to be carried over from the `--glab` scenario directly +below it, where the whole config *directory* is bind-mounted `:ro` and the claim +therefore holds by construction. Under `--aws` only `config` (a single file) and +`sso/` are mounted, which leaves the directory itself on the read-write volume. + +**Not overstated:** that cache only ever holds short-lived SSO/STS-derived +material — the long-lived `credentials` file was never in the container at all — +so anything found in it is expired shortly after the session that wrote it ends. +This is a break in the opt-in boundary, not a live credential leak. It earns a +change because the fix is two lines and the spec already requires the behaviour. + +## What Changes + +- `run.sh`, inside the existing `EPHEMERAL=0` mask block: add `--tmpfs + /root/.aws` when `--aws` is absent, and `--tmpfs /root/.aws/cli/cache` when it + is present. The first restores the opt-in boundary; the second stops an + `--aws` session from leaving derived credentials on the volume behind it. +- Correct `/root/.aws/` from "does not exist" to "is empty" in the no-flags + scenario. A tmpfs mask makes a path exist-but-empty, which is already how the + `glab-cli` and `terraform.d` lines in that same scenario are worded. Literal + non-existence is not something a mount can express, so the original wording + was never satisfiable by the mechanism the rest of the scenario relies on. +- Replace the false EROFS line in the `--aws` scenario with what the mounts + actually guarantee: writes to the two mounted paths fail EROFS, and the + credential cache does not survive the session. +- Extend the masking requirement to cover AWS, so the mechanism and the outcome + live in the same capability. +- Mirror both masks in `smoke/smoke.sh` and assert them in + `smoke/assert-in-container.sh`. +- Add `tests/test_masks.py`, the first test of any kind over the mask set. +- Document the mask in README's `--aws` row. The `--glab` and `--tfe` rows + already state their masking behaviour there, so leaving `--aws` silent + would make it the one flag whose row does not say what it hides. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `external-cli-tools`: `Credentials opt-in` has two scenario assertions + corrected to match a mechanism that now exists, and gains a requirement that + the container's own AWS credential cache not persist. `In-container gh login + persists only under --gh` gains AWS alongside the glab and terraform.d masking + rules it already carries. + +The `In-container gh login persists only under --gh` title is left alone. It +already governs glab and terraform.d, so it is a misnomer before this change and +no more of one after; retitling it would mean a REMOVED plus ADDED pair in the +delta and a rename in the synced spec, for no behavioural gain. + +## Testing + +The gap needs a test that reads `run.sh`, not another container assertion, and +the reason is worth recording because it explains why the existing smoke suite +never caught it. + +`smoke/smoke.sh:256-260` does not invoke `run.sh`. It re-implements the mask +list — its own comment says "mirrors run.sh" — so a mask missing from `run.sh` +cannot fail the smoke suite, because the harness would simply not add it either. +The suite tests the mirror. Separately, +`smoke/assert-in-container.sh` does check that a non-granted opt-in's config +path is absent or empty, but its AWS entry points at `/root/.aws/config`, a file +that is absent without `--aws` whether or not any mask exists, so that assertion +passes vacuously. Both halves of the harness were working as written; neither +could observe this. + +`tests/test_masks.py` therefore asserts the mask set against `run.sh` itself, +and additionally that every mask in `run.sh` appears in the smoke mirror, so the +two cannot drift apart again silently. + +## Impact + +- `run.sh` — two lines in the `EPHEMERAL=0` block. +- `smoke/smoke.sh`, `smoke/assert-in-container.sh` — mirror and assertions. +- `tests/test_masks.py` — new, stdlib only, consistent with the existing unit + tests so CI's test step needs no install step. +- `README.md` — one sentence in the `--aws` flag row. +- No change to the Dockerfile, the image, `entrypoint.sh`, or any host-side + mount. Nothing a user has to migrate: the masked cache re-derives from the + read-only host SSO mount on first use inside an `--aws` session. + +## Out of scope + +Under `--aws` the host `~/.aws/sso/` bind-mount is conditional on that directory +existing on the host (`run.sh:399`). Where it does not, an in-container `aws sso +login` would cache its token on the persistent volume. That state is still +masked from every non-`--aws` session by the first mask, so the opt-in boundary +this change is about holds either way; what remains is persistence between two +`--aws` sessions, which is inside the trust boundary that flag grants. Naming it +here rather than fixing it, because the conditional-on-host-state pattern shows +up in several places in `run.sh` and deserves one change of its own rather than +a partial fix wedged into this one. diff --git a/openspec/changes/archive/2026-09-08-mask-aws-container-state/specs/external-cli-tools/spec.md b/openspec/changes/archive/2026-09-08-mask-aws-container-state/specs/external-cli-tools/spec.md new file mode 100644 index 0000000..c1d4240 --- /dev/null +++ b/openspec/changes/archive/2026-09-08-mask-aws-container-state/specs/external-cli-tools/spec.md @@ -0,0 +1,185 @@ +## MODIFIED Requirements + +### Requirement: Credentials opt-in + +Host credentials (files or env vars) SHALL NOT reach the container unless the user explicitly opts in per-run. `run.sh` defaults to no credential mounts and no token env forwarding. Opt-ins are granted via dedicated flags: + +- `--aws`: mount `~/.aws/config` at `/root/.aws/config:ro` and, when present, `~/.aws/sso/` at `/root/.aws/sso:ro`; forward `AWS_PROFILE`, `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` when set on the host. +- `--gh`: discover the host token from `GH_TOKEN` or `GITHUB_TOKEN`; if neither + is set, `run.sh` SHALL attempt to retrieve the active token by running + `gh auth token` on the host. A discovered token SHALL be provided only to the + per-session auth proxy sidecar (see capability `gh-auth-proxy`) — it SHALL + NOT be forwarded into the agent container, which instead receives the + placeholder `GH_TOKEN=claude-docker-proxy` and reaches GitHub through the + sidecar. If `gh` is not on the host PATH or the command fails, `run.sh` + SHALL continue silently without a token and without a sidecar. +- `--gh-direct`: legacy escape hatch. Same token discovery as `--gh`, but the + token is forwarded directly into the agent container as `GH_TOKEN` and no + sidecar is started. Intended for custom-hostname GitHub (Enterprise Server / + `*.ghe.com`) and hosts that cannot run the sidecar. Passing `--gh` and + `--gh-direct` together SHALL exit with an error. The mode SHALL surface as + a distinct `gh-direct` entry in `CLAUDE_DOCKER_FLAGS` so the statusline tag + distinguishes it from proxied `gh`. +- `--glab`: mount the platform-appropriate glab config dir — `~/Library/Application Support/glab-cli` on macOS, `~/.config/glab-cli` on Linux — at `/root/.config/glab-cli:ro`; forward `GITLAB_TOKEN` when set on the host. +- `--tfe`: when present on the host, mount `~/.terraform.d/credentials.tfrc.json` at `/root/.terraform.d/credentials.tfrc.json:ro`; forward `TF_TOKEN_app_terraform_io` when set on the host. Targets `app.terraform.io` (HCP Terraform); self-hosted Terraform Enterprise hostnames and other `TF_TOKEN_` variables are out of scope for this opt-in. + +All credential bind-mounts SHALL be read-only so a compromised container cannot rewrite host config or tokens. `~/.aws/credentials` and `~/.aws/cli/cache/` SHALL NEVER be mounted, even under `--aws`. + +The container's own `/root/.aws/cli/cache/` SHALL NOT survive the session that +wrote it. The AWS CLI caches assume-role and SSO-derived STS credentials there, +and `/root` is a persistent volume shared by every session, so without a mask +that cache outlives the run whose opt-in produced it. This mirrors the reason +the host path is never mounted: the same material is at stake whether it was +copied in from the host or derived inside the container. + +#### Scenario: No flags means no credentials + +- **GIVEN** host has `~/.aws/config`, `~/.config/glab-cli/config.yml`, `~/.terraform.d/credentials.tfrc.json`, and `GH_TOKEN=ghp_x` and `TF_TOKEN_app_terraform_io=tfc_x` exported +- **AND** a prior container run completed `gh auth login` (state persisted in `claude-code-root`) +- **WHEN** user runs `claude-docker ~/repo` +- **THEN** `/root/.aws/` is empty inside the container +- **AND** `/root/.config/glab-cli/` is empty inside the container +- **AND** `/root/.terraform.d/` is empty inside the container +- **AND** `echo $GH_TOKEN` inside the container is empty +- **AND** `echo $TF_TOKEN_app_terraform_io` inside the container is empty +- **AND** `gh auth status` inside the container reports "not logged in" + +#### Scenario: --aws grants scoped AWS access + +- **GIVEN** the host has completed `aws sso login --profile X` and exports `AWS_PROFILE=X` +- **WHEN** user runs `claude-docker --aws ~/repo` +- **THEN** `aws sts get-caller-identity` inside the container returns the host's identity +- **AND** `~/.aws/credentials` is not present inside the container +- **AND** writes to `/root/.aws/config` from inside the container fail with EROFS +- **AND** writes to `/root/.aws/sso/` from inside the container fail with EROFS +- **AND** `/root/.aws/cli/cache/` is empty at session start and its contents do not persist to a later session + +#### Scenario: --glab grants read-only token access + +- **GIVEN** the host has a valid `~/.config/glab-cli/config.yml` +- **WHEN** user runs `claude-docker --glab ~/repo` +- **THEN** `glab auth status` reports "logged in" without prompting +- **AND** writes to `/root/.config/glab-cli/` from inside the container fail with EROFS + +#### Scenario: --gh keeps the host token out of the agent container + +- **GIVEN** `GH_TOKEN=ghp_x` is exported in the host shell +- **WHEN** user runs `claude-docker --gh ~/repo` +- **THEN** `echo $GH_TOKEN` inside the agent container prints `claude-docker-proxy` +- **AND** `gh api /user` inside the agent container succeeds via the sidecar + +#### Scenario: --gh falls back to gh auth token for the sidecar + +- **GIVEN** neither `GH_TOKEN` nor `GITHUB_TOKEN` is set in the host shell +- **AND** the host has `gh` on PATH and the user is authenticated (`gh auth status` succeeds) +- **WHEN** user runs `claude-docker --gh ~/repo` +- **THEN** authenticated GitHub access works inside the agent container +- **AND** the token returned by host `gh auth token` is not present in the agent container's environment + +#### Scenario: --gh is silent when gh is unavailable + +- **GIVEN** neither `GH_TOKEN` nor `GITHUB_TOKEN` is set in the host shell +- **AND** `gh` is not on the host PATH (or `gh auth token` exits non-zero) +- **WHEN** user runs `claude-docker --gh ~/repo` +- **THEN** the container starts without a sidecar, without a `GH_TOKEN` env var, and no error is printed + +#### Scenario: --gh-direct restores legacy forwarding + +- **GIVEN** `GH_TOKEN=ghp_x` is exported in the host shell +- **WHEN** user runs `claude-docker --gh-direct ~/repo` +- **THEN** `echo $GH_TOKEN` inside the agent container prints `ghp_x` +- **AND** no sidecar container is started + +#### Scenario: --gh and --gh-direct together are rejected + +- **WHEN** user runs `claude-docker --gh --gh-direct ~/repo` +- **THEN** `run.sh` exits non-zero with an error naming the conflicting flags +- **AND** no container or sidecar is started + +#### Scenario: --tfe mounts host TFC credentials read-only + +- **GIVEN** the host has a valid `~/.terraform.d/credentials.tfrc.json` with an `app.terraform.io` token entry +- **WHEN** user runs `claude-docker --tfe ~/repo` +- **THEN** `/root/.terraform.d/credentials.tfrc.json` inside the container contains the host file's contents +- **AND** writes to `/root/.terraform.d/credentials.tfrc.json` from inside the container fail with EROFS + +#### Scenario: --tfe forwards host TF_TOKEN_app_terraform_io + +- **GIVEN** `TF_TOKEN_app_terraform_io=tfc_xyz` is exported in the host shell +- **WHEN** user runs `claude-docker --tfe ~/repo` +- **THEN** `echo $TF_TOKEN_app_terraform_io` inside the container prints `tfc_xyz` + +#### Scenario: --tfe is silent when neither file nor env var is set + +- **GIVEN** the host has no `~/.terraform.d/credentials.tfrc.json` and no `TF_TOKEN_app_terraform_io` exported +- **WHEN** user runs `claude-docker --tfe ~/repo` +- **THEN** the container starts without error +- **AND** `/root/.terraform.d/` inside the container is empty +- **AND** `echo $TF_TOKEN_app_terraform_io` inside the container is empty + +### Requirement: In-container gh login persists only under --gh + +Because macOS `gh` uses the Keychain (no host file to mount), the container SHALL support a fresh `gh auth login` whose resulting `~/.config/gh/` persists across runs via the existing `claude-code-root` volume. Access to that persisted state SHALL be gated on the current run actually needing it: `/root/.config/gh/` inside the container MUST appear empty (achieved by overlaying a tmpfs mask) unless the run is `--gh` with no host token found (in-container login is the remaining auth path) or `--gh-direct`. In particular, the mask SHALL stay ON when the auth proxy sidecar is active — the placeholder env token makes persisted login state unnecessary, and leaving it accessible would reintroduce a persisted in-container secret. When `--gh` is absent entirely, the mask applies as before. The same masking rule SHALL apply to `/root/.config/glab-cli/` when `--glab` is not set, and to `/root/.terraform.d/` when `--tfe` is not set (covering tokens written by an in-container `terraform login` that would otherwise persist via `claude-code-root`). + +The rule SHALL extend to `/root/.aws/` when `--aws` is not set. AWS has no +in-container `auth login` step, so it was omitted when this requirement was +written for the CLIs that do, but the persistence is identical: an `--aws` +session's derived credential cache is written under `/root`, and `/root` is the +shared volume. Under `--aws` the mask SHALL narrow to `/root/.aws/cli/cache/` +rather than covering the whole directory, so the read-only host mounts at +`/root/.aws/config` and `/root/.aws/sso/` remain visible to the session that +asked for them. + +Masking SHALL NOT be conditional on any host-side path existing. A mask whose +presence depends on host state protects some machines and not others, and gives +the user no way to tell which. + +#### Scenario: gh login survives container exit under --gh without a host token + +- **GIVEN** the host has no GitHub token (no env vars, `gh auth token` fails) +- **AND** user completes `gh auth login` inside a container launched with `--gh` +- **WHEN** they exit and relaunch with `--gh` (host still has no token) +- **THEN** `gh auth status` reports "logged in" without re-prompting + +#### Scenario: persisted gh login is masked while the sidecar is active + +- **GIVEN** a prior container run completed `gh auth login` (state persisted in `claude-code-root`) +- **AND** the host has a GitHub token so the sidecar starts +- **WHEN** user runs `claude-docker --gh ~/repo` +- **THEN** `/root/.config/gh/` inside the agent container is empty +- **AND** GitHub access works via the sidecar placeholder token + +#### Scenario: prior gh login is hidden without --gh + +- **GIVEN** a prior container run completed `gh auth login` (state persisted in `claude-code-root`) +- **WHEN** user runs `claude-docker ~/repo` without `--gh` +- **THEN** `gh auth status` inside the container reports "not logged in" +- **AND** `/root/.config/gh/` inside the container is empty + +#### Scenario: prior glab login is hidden without --glab + +- **GIVEN** a prior container run completed `glab auth login` (state persisted in `claude-code-root`) +- **WHEN** user runs `claude-docker ~/repo` without `--glab` +- **THEN** `glab auth status` inside the container reports no authenticated host +- **AND** `/root/.config/glab-cli/` inside the container is empty + +#### Scenario: prior terraform login is hidden without --tfe + +- **GIVEN** a prior container run completed `terraform login app.terraform.io` (the resulting credentials file persists under `claude-code-root` in `/root/.terraform.d/`) +- **WHEN** user runs `claude-docker ~/repo` without `--tfe` +- **THEN** `/root/.terraform.d/` inside the container is empty +- **AND** no `credentials.tfrc.json` from the prior session is readable inside the container + +#### Scenario: prior AWS credential cache is hidden without --aws + +- **GIVEN** a prior container run used `--aws` and the AWS CLI cached STS credentials under `/root/.aws/cli/cache/` on the `claude-code-root` volume +- **WHEN** user runs `claude-docker ~/repo` without `--aws` +- **THEN** `/root/.aws/` inside the container is empty +- **AND** no cached credential from the prior session is readable inside the container + +#### Scenario: an --aws session leaves no credential cache behind + +- **GIVEN** the host has completed `aws sso login` and `~/.aws/sso/` exists +- **WHEN** user runs `claude-docker --aws ~/repo` and the AWS CLI derives and caches STS credentials +- **THEN** `/root/.aws/config` is readable inside that container +- **AND** on the next `claude-docker --aws ~/repo`, `/root/.aws/cli/cache/` is empty at session start diff --git a/openspec/changes/archive/2026-09-08-mask-aws-container-state/tasks.md b/openspec/changes/archive/2026-09-08-mask-aws-container-state/tasks.md new file mode 100644 index 0000000..3ffc555 --- /dev/null +++ b/openspec/changes/archive/2026-09-08-mask-aws-container-state/tasks.md @@ -0,0 +1,38 @@ +## 1. Spec + +- [x] 1.1 Proposal: the host/container asymmetry, the two false scenarios, why the outcome lives in one requirement and the mechanism in another, and why the severity is a boundary break rather than a live credential leak +- [x] 1.2 Spec delta: `Credentials opt-in` gains the non-persistence rule and has the two scenario assertions corrected; `In-container gh login persists only under --gh` gains AWS and a no-host-conditional-masking rule, plus two AWS scenarios +- [x] 1.3 Record the conditional `~/.aws/sso` host mount as out of scope, so the partial fix is stated rather than implied + +## 2. Wrapper + +- [x] 2.1 `run.sh`: `--tmpfs /root/.aws` when `WITH_AWS=0`, inside the existing `EPHEMERAL=0` block so `--ephemeral` (which mounts no volumes at all) is unaffected +- [x] 2.2 `run.sh`: `--tmpfs /root/.aws/cli/cache` when `WITH_AWS=1`, narrow enough to leave the `:ro` host mounts at `/root/.aws/config` and `/root/.aws/sso` visible +- [x] 2.3 Comment the pair with why AWS differs from gh/glab/tfe: no in-container login step, so the mask is unconditional rather than three-state + +## 3. Tests + +- [x] 3.1 `tests/test_masks.py`: assert the exact mask set in `run.sh` with each mask's guard, so removing or weakening one fails +- [x] 3.2 Same file: assert every mask in `run.sh` also appears in `smoke/smoke.sh`, closing the drift that let this gap survive +- [x] 3.3 `smoke/smoke.sh`: mirror both new masks +- [x] 3.4 `smoke/assert-in-container.sh`: dedicated AWS assertion — `/root/.aws` empty without `--aws`, `/root/.aws/cli/cache` empty with it. Not reachable by repointing `optin_config_path`, whose value is shared with the granted branch that asserts the path is read-only +- [x] 3.5 Confirm the pre-change smoke suite could not have caught this, and record it in the proposal rather than only in the commit message +- [x] 3.6 Assert the `--aws` cache mask stays *writable*. It is the only mask that must accept writes — the AWS CLI writes derived STS into it — and `entrypoint.sh`'s chown walk uses `find -xdev`, so it never descends into a tmpfs and cannot fix ownership there. Relies on Docker's default `--tmpfs` mode; asserted rather than assumed + +## 4. Documentation + +- [x] 4.0 README `--aws` row: what the mask hides without the flag and what it narrows to with it, matching how the `--glab` and `--tfe` rows already read + +## 5. Verification + +- [x] 5.1 `shellcheck --severity=warning` (CI's threshold) over `run.sh`, `entrypoint.sh` and both smoke scripts — clean. Not installed in the dev image and no root/pip available there; `uv tool run --from shellcheck-py shellcheck` works +- [x] 5.2 `python3 -m unittest discover -s tests -p 'test_*.py'` — 106 tests, all pass +- [x] 5.3 `openspec validate mask-aws-container-state --strict` +- [x] 5.4 Assert the new test fails against the unfixed `run.sh` — a mask test that passes before the fix tests nothing. Confirmed: `test_mask_set_is_exactly_the_reviewed_set` and `test_aws_is_masked_in_both_directions_with_a_narrower_scope_under_aws` fail on `HEAD:run.sh`, the other four pass +- [x] 5.5 `bash -n` on every edited shell script +- [ ] 5.6 `docker build -t claude-code:local .` and `IMAGE=claude-code:local bash smoke/smoke.sh --uid="$(id -u)" --optins=aws,glab,tfe` — needs a Docker daemon, so left to CI's `docker-build` job + +## 6. Follow-ups (not this change) + +- [ ] 6.1 The conditional-on-host-state masking pattern elsewhere in `run.sh` (config overlays, the `settings.json` reseed) — one change of its own +- [ ] 6.2 `hadolint` is unaffected here (no Dockerfile change) but is part of the pre-PR check list diff --git a/openspec/specs/external-cli-tools/spec.md b/openspec/specs/external-cli-tools/spec.md index 618f0c6..c3292fc 100644 --- a/openspec/specs/external-cli-tools/spec.md +++ b/openspec/specs/external-cli-tools/spec.md @@ -45,12 +45,19 @@ Host credentials (files or env vars) SHALL NOT reach the container unless the us All credential bind-mounts SHALL be read-only so a compromised container cannot rewrite host config or tokens. `~/.aws/credentials` and `~/.aws/cli/cache/` SHALL NEVER be mounted, even under `--aws`. +The container's own `/root/.aws/cli/cache/` SHALL NOT survive the session that +wrote it. The AWS CLI caches assume-role and SSO-derived STS credentials there, +and `/root` is a persistent volume shared by every session, so without a mask +that cache outlives the run whose opt-in produced it. This mirrors the reason +the host path is never mounted: the same material is at stake whether it was +copied in from the host or derived inside the container. + #### Scenario: No flags means no credentials - **GIVEN** host has `~/.aws/config`, `~/.config/glab-cli/config.yml`, `~/.terraform.d/credentials.tfrc.json`, and `GH_TOKEN=ghp_x` and `TF_TOKEN_app_terraform_io=tfc_x` exported - **AND** a prior container run completed `gh auth login` (state persisted in `claude-code-root`) - **WHEN** user runs `claude-docker ~/repo` -- **THEN** `/root/.aws/` does not exist inside the container +- **THEN** `/root/.aws/` is empty inside the container - **AND** `/root/.config/glab-cli/` is empty inside the container - **AND** `/root/.terraform.d/` is empty inside the container - **AND** `echo $GH_TOKEN` inside the container is empty @@ -63,7 +70,9 @@ All credential bind-mounts SHALL be read-only so a compromised container cannot - **WHEN** user runs `claude-docker --aws ~/repo` - **THEN** `aws sts get-caller-identity` inside the container returns the host's identity - **AND** `~/.aws/credentials` is not present inside the container -- **AND** writes to `/root/.aws/` from inside the container fail with EROFS +- **AND** writes to `/root/.aws/config` from inside the container fail with EROFS +- **AND** writes to `/root/.aws/sso/` from inside the container fail with EROFS +- **AND** `/root/.aws/cli/cache/` is empty at session start and its contents do not persist to a later session #### Scenario: --glab grants read-only token access @@ -132,6 +141,19 @@ All credential bind-mounts SHALL be read-only so a compromised container cannot Because macOS `gh` uses the Keychain (no host file to mount), the container SHALL support a fresh `gh auth login` whose resulting `~/.config/gh/` persists across runs via the existing `claude-code-root` volume. Access to that persisted state SHALL be gated on the current run actually needing it: `/root/.config/gh/` inside the container MUST appear empty (achieved by overlaying a tmpfs mask) unless the run is `--gh` with no host token found (in-container login is the remaining auth path) or `--gh-direct`. In particular, the mask SHALL stay ON when the auth proxy sidecar is active — the placeholder env token makes persisted login state unnecessary, and leaving it accessible would reintroduce a persisted in-container secret. When `--gh` is absent entirely, the mask applies as before. The same masking rule SHALL apply to `/root/.config/glab-cli/` when `--glab` is not set, and to `/root/.terraform.d/` when `--tfe` is not set (covering tokens written by an in-container `terraform login` that would otherwise persist via `claude-code-root`). +The rule SHALL extend to `/root/.aws/` when `--aws` is not set. AWS has no +in-container `auth login` step, so it was omitted when this requirement was +written for the CLIs that do, but the persistence is identical: an `--aws` +session's derived credential cache is written under `/root`, and `/root` is the +shared volume. Under `--aws` the mask SHALL narrow to `/root/.aws/cli/cache/` +rather than covering the whole directory, so the read-only host mounts at +`/root/.aws/config` and `/root/.aws/sso/` remain visible to the session that +asked for them. + +Masking SHALL NOT be conditional on any host-side path existing. A mask whose +presence depends on host state protects some machines and not others, and gives +the user no way to tell which. + #### Scenario: gh login survives container exit under --gh without a host token - **GIVEN** the host has no GitHub token (no env vars, `gh auth token` fails) @@ -168,6 +190,20 @@ Because macOS `gh` uses the Keychain (no host file to mount), the container SHAL - **THEN** `/root/.terraform.d/` inside the container is empty - **AND** no `credentials.tfrc.json` from the prior session is readable inside the container +#### Scenario: prior AWS credential cache is hidden without --aws + +- **GIVEN** a prior container run used `--aws` and the AWS CLI cached STS credentials under `/root/.aws/cli/cache/` on the `claude-code-root` volume +- **WHEN** user runs `claude-docker ~/repo` without `--aws` +- **THEN** `/root/.aws/` inside the container is empty +- **AND** no cached credential from the prior session is readable inside the container + +#### Scenario: an --aws session leaves no credential cache behind + +- **GIVEN** the host has completed `aws sso login` and `~/.aws/sso/` exists +- **WHEN** user runs `claude-docker --aws ~/repo` and the AWS CLI derives and caches STS credentials +- **THEN** `/root/.aws/config` is readable inside that container +- **AND** on the next `claude-docker --aws ~/repo`, `/root/.aws/cli/cache/` is empty at session start + ### Requirement: git-lfs installed and LFS filters registered The container image SHALL ship with `git-lfs` on the default PATH so that git @@ -229,4 +265,3 @@ The container image SHALL ship with `tfenv` on the default PATH so users can fet - **WHEN** the user runs `tfenv install` inside the container - **THEN** tfenv downloads terraform 1.9.5 from `releases.hashicorp.com` and installs it - **AND** subsequent `terraform version` invocations report `1.9.5` - diff --git a/run.sh b/run.sh index f5623d6..1168201 100755 --- a/run.sh +++ b/run.sh @@ -858,6 +858,20 @@ if [ "$EPHEMERAL" = "0" ]; then [ "$gh_config_unmask" = "0" ] && MOUNT_ARGS+=("--tmpfs" "/root/.config/gh") [ "$WITH_GLAB" = "0" ] && MOUNT_ARGS+=("--tmpfs" "/root/.config/glab-cli") [ "$WITH_TFE" = "0" ] && MOUNT_ARGS+=("--tmpfs" "/root/.terraform.d") + # AWS has no in-container `login` step to preserve, so unlike gh it needs no + # unmask state — the mask is on in both directions, only its scope changes. + # Without --aws the whole directory is masked. With it, just the credential + # cache, so the :ro host mounts at /root/.aws/config and /root/.aws/sso stay + # visible to the session that asked for them. That cache is the point: the + # scoped host mount above deliberately refuses to import ~/.aws/cli/cache + # because it holds assume-role STS, and the CLI writes the same material to + # the container's own copy — which, unmasked, persists on claude-code-root + # and reintroduces it by the back door. + if [ "$WITH_AWS" = "0" ]; then + MOUNT_ARGS+=("--tmpfs" "/root/.aws") + else + MOUNT_ARGS+=("--tmpfs" "/root/.aws/cli/cache") + fi MOUNT_ARGS=(-v claude-code-root:/root -v claude-code-home:/root/.claude "${MOUNT_ARGS[@]}") fi diff --git a/smoke/assert-in-container.sh b/smoke/assert-in-container.sh index e90146d..e1bb241 100644 --- a/smoke/assert-in-container.sh +++ b/smoke/assert-in-container.sh @@ -368,6 +368,93 @@ check_credentials() { done } +# AWS needs its own check, and cannot be folded into the loop above. +# optin_config_path returns /root/.aws/config, and that value is shared with the +# granted branch, which asserts the path is read-only. /root/.aws is not — only +# the two entries mounted inside it are — so repointing the mapping at the +# directory would make the granted branch fail. +# +# The loop is also blind to what matters here: without --aws, /root/.aws/config +# is absent whether or not a mask exists, so the masked-aws assertion passed +# vacuously and the credential cache beside it went unexamined. +check_aws_state_masking() { + local granted=0 + case ",${EXPECT_OPTINS:-}," in + *,aws,*) granted=1 ;; + esac + + # --ephemeral mounts no named volumes, so there is nothing to mask and run.sh + # adds no tmpfs (the whole mask block is inside its EPHEMERAL=0 branch). The + # property still worth asserting is the one the masks exist to provide: no + # AWS state carried in from anywhere. Requiring a tmpfs here would fail the + # ephemeral cell for doing exactly what it is supposed to do. + if [ "${EXPECT_EPHEMERAL:-0}" = "1" ]; then + local cache="/root/.aws/cli/cache" + if [ ! -e "$cache" ] || [ "$(find "$cache" -mindepth 1 2>/dev/null | wc -l)" -eq 0 ]; then + pass "ephemeral-aws: no AWS credential cache present (no volumes mounted)" + else + fail "ephemeral-aws: ${cache} is populated in an --ephemeral session" + fi + return + fi + + local path label + if [ "$granted" = "1" ]; then + # Under --aws the mask narrows to the credential cache: the AWS CLI writes + # assume-role/SSO-derived STS there, and /root is a shared persistent + # volume, so it must not outlive this session. + path="/root/.aws/cli/cache" + label="aws-cache-masked" + else + path="/root/.aws" + label="masked-aws-dir" + fi + + if [ ! -e "$path" ]; then + fail "${label}: ${path} does not exist (expected an empty tmpfs mask)" + return + fi + + local entry_count + entry_count=$(find "$path" -mindepth 1 2>/dev/null | wc -l) + if [ "$entry_count" -eq 0 ]; then + pass "${label}: ${path} is present and empty (tmpfs mask)" + else + fail "${label}: ${path} unexpectedly populated ($entry_count entries)" + fi + + # A tmpfs, not merely an empty directory on the volume. Without this the + # assertion above would pass on a first-ever run, before any session had + # written anything, and report a mask that is not there. + if grep -qE "[[:space:]]${path}[[:space:]]+tmpfs[[:space:]]" /proc/self/mounts; then + pass "${label}-mount: ${path} is a tmpfs mount" + else + fail "${label}-mount: ${path} is not a tmpfs mount (no mask applied)" + fi + + # Under --aws the read-only host mounts must survive the narrower mask. + if [ "$granted" = "1" ]; then + if [ -f /root/.aws/config ]; then + pass "aws-cache-masked-scope: /root/.aws/config still visible under the cache mask" + else + fail "aws-cache-masked-scope: /root/.aws/config hidden — mask is too broad" + fi + + # This is the one mask that must stay WRITABLE. Every other mask exists to + # deny access, but the AWS CLI writes its derived STS credentials into this + # directory, so an unwritable mask would break `aws` under the very flag + # that grants it. Nothing else guarantees it: entrypoint.sh's chown walk + # runs `find /root ... -xdev`, which by design does not descend into a + # tmpfs, so ownership here is whatever the mount gave us. + if touch /root/.aws/cli/cache/__smoke_write_test 2>/dev/null; then + pass "aws-cache-writable: the cache mask accepts writes from the session user" + rm -f /root/.aws/cli/cache/__smoke_write_test + else + fail "aws-cache-writable: cannot write to /root/.aws/cli/cache as $(id -u):$(id -g) — the AWS CLI would fail to cache credentials under --aws" + fi + fi +} + # --------------------------------------------------------------------------- # 6. Seeded settings — must be a writable copy, not a mount # --------------------------------------------------------------------------- @@ -452,6 +539,7 @@ check_security check_path_order check_workspace_write check_credentials +check_aws_state_masking check_settings echo "===" diff --git a/smoke/smoke.sh b/smoke/smoke.sh index 6529b64..d9a787f 100644 --- a/smoke/smoke.sh +++ b/smoke/smoke.sh @@ -140,6 +140,7 @@ ENV_ARGS=( "-e" "EXPECT_GID=${HOST_GID_ARG}" "-e" "EXPECT_OPTINS=${OPTINS}" "-e" "EXPECT_RO=${RO}" + "-e" "EXPECT_EPHEMERAL=${EPHEMERAL}" "-e" "WORKSPACE=${CONTAINER_WORKSPACE}" ) @@ -258,6 +259,14 @@ if [ "${EPHEMERAL}" = "0" ]; then VOLUME_ARGS+=("--tmpfs" "/root/.config/gh") [ "${WITH_GLAB}" = "0" ] && VOLUME_ARGS+=("--tmpfs" "/root/.config/glab-cli") [ "${WITH_TFE}" = "0" ] && VOLUME_ARGS+=("--tmpfs" "/root/.terraform.d") + # AWS is masked in both directions, only the scope changes (see run.sh). + # tests/test_masks.py asserts this mirror stays in step with run.sh — the + # mirror is why a mask missing from run.sh cannot fail this suite on its own. + if [ "${WITH_AWS}" = "0" ]; then + VOLUME_ARGS+=("--tmpfs" "/root/.aws") + else + VOLUME_ARGS+=("--tmpfs" "/root/.aws/cli/cache") + fi fi # --------------------------------------------------------------------------- diff --git a/tests/test_masks.py b/tests/test_masks.py new file mode 100644 index 0000000..5ddbf1f --- /dev/null +++ b/tests/test_masks.py @@ -0,0 +1,205 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Schuberg Philis +"""Pin the tmpfs mask set that enforces the credential opt-in boundary. + +The masks in `run.sh` are the entire mechanism keeping one session's persisted +credential state out of the next session's reach. `/root` and `/root/.claude` +are named volumes, mounted read-write and shared by every container, so a +credential path under them that carries no mask persists after the session that +wrote it and is readable by a later session that opted into nothing. + +Nothing tested that set before this file. The smoke suite looks like it does, +but `smoke/smoke.sh` re-implements the mask list rather than invoking `run.sh` — +its own comment says "mirrors run.sh" — so a mask missing from `run.sh` cannot +fail the smoke suite, because the harness would simply not add it either. That +is how `/root/.aws` stayed off the list while `external-cli-tools/spec.md` +asserted it was masked. + +This test therefore reads `run.sh` itself, and separately asserts the mirror +still matches it, since the drifting mirror is what made the gap invisible. + +Fail-closed: the mask set is asserted by equality, not containment, so a mask +added without a spec delta and a test update fails here rather than shipping +unreviewed. + +Stdlib only, so CI's unit-test step keeps running with no install step. +""" + +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +RUN_SH = ROOT / "run.sh" +SMOKE_SH = ROOT / "smoke" / "smoke.sh" + +# Every path run.sh is expected to mask, mapped to the shell variable that +# decides it. Equality-checked, so this dict is the reviewed mask set. +EXPECTED_MASKS = { + "/root/.config/gh": "gh_config_unmask", + "/root/.config/glab-cli": "WITH_GLAB", + "/root/.terraform.d": "WITH_TFE", + "/root/.aws": "WITH_AWS", + "/root/.aws/cli/cache": "WITH_AWS", +} + +# The three single-line masks: applied when the opt-in variable is "0", i.e. +# when the flag is absent. A mask that became unconditional, or got attached to +# the wrong flag, fails to match. +SINGLE_LINE_MASKS = { + "/root/.config/gh": "gh_config_unmask", + "/root/.config/glab-cli": "WITH_GLAB", + "/root/.terraform.d": "WITH_TFE", +} + +TMPFS = re.compile(r'MOUNT_ARGS\+=\("--tmpfs" "([^"]+)"\)') +SMOKE_TMPFS = re.compile(r'VOLUME_ARGS\+=\("--tmpfs" "([^"]+)"\)') + + +def run_sh(): + return RUN_SH.read_text() + + +class MaskSetTest(unittest.TestCase): + def test_mask_set_is_exactly_the_reviewed_set(self): + found = set(TMPFS.findall(run_sh())) + self.assertEqual( + found, + set(EXPECTED_MASKS), + "run.sh's tmpfs mask set changed. A removed mask lets a prior " + "session's credential state persist into a session that did not " + "opt in; an added one needs a spec delta. Update " + "EXPECTED_MASKS and openspec/specs/external-cli-tools/spec.md " + "together.", + ) + + def test_single_line_masks_are_gated_on_the_optin_being_off(self): + text = run_sh() + for path, guard in SINGLE_LINE_MASKS.items(): + pattern = re.compile( + r'\[ "\$' + re.escape(guard) + r'" = "0" \]\s*&&\s*' + r'MOUNT_ARGS\+=\("--tmpfs" "' + re.escape(path) + r'"\)' + ) + self.assertRegex( + text, + pattern, + f"{path} is no longer masked by [ ${guard} = 0 ]. Either the " + f"mask became unconditional or it is gated on the wrong flag.", + ) + + def test_gh_mask_keeps_its_three_state_logic(self): + """gh is the one mask with three states, not two. + + It must stay ON while the auth proxy sidecar is active: the sidecar + hands the container a placeholder token, so persisted login state is + unnecessary, and leaving it readable would reintroduce a persisted + in-container secret. Collapsing this to `[ $WITH_GH = 0 ]` would unmask + it for every --gh run. + """ + text = run_sh() + self.assertRegex( + text, + r'\[ "\$WITH_GH_DIRECT" = "1" \]\s*&&\s*gh_config_unmask=1', + "--gh-direct no longer unmasks the gh config dir", + ) + self.assertRegex( + text, + r'\[ "\$WITH_GH" = "1" \]\s*&&\s*\[ "\$GH_SIDECAR_ACTIVE" = "0" \]' + r'\s*&&\s*gh_config_unmask=1', + "the gh unmask no longer requires the sidecar to be inactive — a " + "--gh run with a sidecar would expose persisted login state", + ) + + def test_aws_is_masked_in_both_directions_with_a_narrower_scope_under_aws(self): + """AWS is the only two-scope mask, and both halves are load-bearing. + + Without --aws the whole directory is masked, which is what keeps an + --aws session's cached STS out of a no-flag session. With --aws the mask + narrows to the credential cache so the :ro host mounts at + /root/.aws/config and /root/.aws/sso stay visible — a mask over the + whole directory there would hide the credentials the flag just granted. + """ + collapsed = re.sub(r"\s+", " ", run_sh()) + expected = ( + 'if [ "$WITH_AWS" = "0" ]; then ' + 'MOUNT_ARGS+=("--tmpfs" "/root/.aws") ' + "else " + 'MOUNT_ARGS+=("--tmpfs" "/root/.aws/cli/cache") ' + "fi" + ) + # assertIn would embed the whole collapsed script in the failure + # message, burying the one line that matters under 40KB of run.sh. + self.assertTrue( + expected in collapsed, + "the AWS mask pair changed shape or is missing. Expected, " + f"ignoring whitespace:\n\n {expected}\n\n" + "Both branches matter: the if-branch enforces the opt-in boundary, " + "the else-branch stops an --aws session leaving derived " + "credentials on the shared volume while keeping the granted :ro " + "mounts visible.", + ) + + def test_every_mask_sits_inside_the_non_ephemeral_block(self): + """A mask outside the block is either dead or misleading. + + The masks only mean anything when the named volumes are mounted; + --ephemeral mounts none, so it needs no masking. A mask that drifted out + of the block would still be appended to MOUNT_ARGS, but the array is + rebuilt at the end of the block, so placement is not cosmetic. + """ + lines = run_sh().splitlines() + starts = [ + i for i, line in enumerate(lines) + if re.match(r'\s*if \[ "\$EPHEMERAL" = "0" \]; then', line) + ] + self.assertEqual( + len(starts), 1, + "expected exactly one EPHEMERAL=0 block guarding the volume mounts", + ) + start = starts[0] + + ends = [ + i for i, line in enumerate(lines) + if "MOUNT_ARGS=(-v claude-code-root:/root" in line + ] + self.assertEqual( + len(ends), 1, + "expected exactly one line prepending the named volumes", + ) + end = ends[0] + self.assertGreater(end, start) + + for i, line in enumerate(lines): + if TMPFS.search(line): + self.assertTrue( + start < i < end, + f"tmpfs mask on line {i + 1} sits outside the " + f"EPHEMERAL=0 block (lines {start + 1}-{end + 1}): " + f"{line.strip()}", + ) + + +class SmokeMirrorTest(unittest.TestCase): + """The smoke harness mirrors the mask list instead of calling run.sh. + + That is a deliberate trade-off — it lets the harness exercise credential + combinations without the host-side discovery run.sh does — but it means the + mirror can silently fall behind, and a mask present only in run.sh is never + exercised in a container. Tie the two together here. + """ + + def test_mirror_covers_every_mask_run_sh_applies(self): + run_masks = set(TMPFS.findall(run_sh())) + smoke_masks = set(SMOKE_TMPFS.findall(SMOKE_SH.read_text())) + missing = run_masks - smoke_masks + self.assertEqual( + missing, + set(), + "smoke/smoke.sh no longer mirrors every mask in run.sh: " + f"{sorted(missing)} would go unexercised in the container " + "assertions. Add them to the mirror in smoke.sh.", + ) + + +if __name__ == "__main__": + unittest.main()