diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea46775..d752468 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,10 @@ on: schedule: # Weekly re-scan (Trivy misconfig + gitleaks) against updated policy/rule DBs # even when the repo is dormant. Monday 04:00 UTC, staggered from CodeQL (03:00). + # Only trivy-scan + secrets-scan run on this trigger — the deterministic jobs + # (shell/secrets/vendored/dockerfile/squid/egress-test) are gated off with + # `if: github.event_name != 'schedule'` since their output cannot change on a + # dormant repo; the gate job tolerates their `skipped` result on schedule runs. - cron: "0 4 * * 1" workflow_dispatch: @@ -48,6 +52,10 @@ jobs: name: shell scripts (syntax + shellcheck) runs-on: ubuntu-24.04 timeout-minutes: 10 + # Deterministic on a dormant repo — skip on the weekly schedule (which exists + # only to re-scan against fresh Trivy/gitleaks rule DBs). Same gate on the + # other deterministic jobs below. + if: github.event_name != 'schedule' permissions: contents: read steps: @@ -76,6 +84,7 @@ jobs: name: leak audit (no baked secrets) runs-on: ubuntu-24.04 timeout-minutes: 10 + if: github.event_name != 'schedule' permissions: contents: read steps: @@ -105,6 +114,7 @@ jobs: name: in-repo vendored copies + generated allowlist match canonical runs-on: ubuntu-24.04 timeout-minutes: 10 + if: github.event_name != 'schedule' permissions: contents: read steps: @@ -122,6 +132,7 @@ jobs: name: dockerfile lint runs-on: ubuntu-24.04 timeout-minutes: 10 + if: github.event_name != 'schedule' permissions: contents: read steps: @@ -139,6 +150,7 @@ jobs: name: squid.conf syntax (squid -k parse) runs-on: ubuntu-24.04 timeout-minutes: 10 + if: github.event_name != 'schedule' permissions: contents: read steps: @@ -166,20 +178,36 @@ jobs: name: devcontainer builds + egress lock enforces runs-on: ubuntu-24.04 timeout-minutes: 30 + if: github.event_name != 'schedule' permissions: contents: read steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4 - - name: build image + functional egress smoke test - # Two things at once: (a) the Dockerfile is BUILT (hadolint/Trivy only - # lint/scan it), catching a build that lints clean but breaks; (b) the image - # is BOOTED and the egress lock is asserted end-to-end. The gating assertions - # (firewall sentinel up, off-allowlist host blocked, non-CONNECT cleartext - # refused) are internet-INDEPENDENT — squid denies them locally — so this is a - # deterministic gate; only the on-allowlist reachability check needs egress and - # it is a soft warning. Needs NET_ADMIN for iptables, hence a real boot here - # rather than in the self-contained lint jobs above. - run: RUNTIME=docker IMAGE=dev-sandbox:ci ./test/egress-smoke.sh + - name: set up buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + - name: build image (GHA layer cache) + # The Dockerfile is BUILT here (hadolint/Trivy only lint/scan it), catching a + # build that lints clean but breaks. Layers are cached in the GHA cache + # backend: the Dockerfile is deliberately ordered so routine sync.sh edits + # touch only the final cheap COPY layers, and without a persisted cache CI + # re-paid the full apt + Node/Python download + npm install build (~4-8 min, + # the long pole of the pipeline) on every run. Cache-hit builds take seconds. + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: sandbox/.devcontainer + tags: dev-sandbox:ci + load: true + cache-from: type=gha + cache-to: type=gha,mode=max + - name: functional egress smoke test + # The image is BOOTED and the egress lock asserted end-to-end. The gating + # assertions (firewall sentinel up, off-allowlist host blocked, non-CONNECT + # cleartext refused) are internet-INDEPENDENT — squid denies them locally — so + # this is a deterministic gate; only the on-allowlist reachability check needs + # egress and it is a soft warning. Needs NET_ADMIN for iptables, hence a real + # boot here rather than in the self-contained lint jobs above. SKIP_BUILD=1 + # reuses the image built (with layer caching) in the previous step. + run: RUNTIME=docker IMAGE=dev-sandbox:ci SKIP_BUILD=1 ./test/egress-smoke.sh # ───────────────────────────────────────────────────────────────────────────── # Trivy — Dockerfile / IaC misconfiguration scan (HIGH/CRITICAL). The `misconfig` @@ -200,18 +228,11 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4 - - name: Run Trivy scan (table — fail on HIGH/CRITICAL) - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 - with: - scan-type: fs - scan-ref: . - scanners: misconfig - format: table - severity: HIGH,CRITICAL - exit-code: "1" - - - name: Run Trivy scan (SARIF — upload to GitHub Security) - if: always() + - name: Run Trivy scan (SARIF — fail on HIGH/CRITICAL) + # ONE scan does both duties: Trivy writes the SARIF report BEFORE applying + # exit-code, so this step gates the pipeline on HIGH/CRITICAL findings AND + # produces the Security-tab upload — the previous separate table + SARIF + # steps re-ran an identical scan (~20-40s) for no additional signal. uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: scan-type: fs @@ -220,7 +241,7 @@ jobs: format: sarif output: trivy-results.sarif severity: HIGH,CRITICAL - exit-code: "0" + exit-code: "1" - name: Upload Trivy SARIF to GitHub Security if: always() @@ -230,16 +251,19 @@ jobs: category: trivy # ───────────────────────────────────────────────────────────────────────────── - # Quality Gate — every check above must pass. Aggregates results so a failure - # in any job surfaces as one status. + # CI Complete — single required status check covering the whole pipeline: every + # job above must pass. Set "CI Complete" as the required check in branch + # protection settings. (Formerly a quality-gate → ci-complete chain of two jobs + # doing the same aggregation — the second runner allocation added ~20-40s to + # time-to-green on every run for no additional signal.) # ───────────────────────────────────────────────────────────────────────────── - quality-gate: - name: Quality Gate + ci-complete: + name: CI Complete runs-on: ubuntu-24.04 timeout-minutes: 5 - # INVARIANT: every job above (except ci-complete) MUST be listed here. A job - # that runs but is omitted from `needs` can fail while this gate stays green — - # so when you add a job, add it to this list too. + # INVARIANT: every job above MUST be listed here. A job that runs but is + # omitted from `needs` can fail while this gate stays green — so when you add + # a job, add it to this list too. needs: - shell - secrets @@ -254,31 +278,18 @@ jobs: - name: Check all gates passed run: | results='${{ toJson(needs.*.result) }}' - # `skipped` counts as a failure too: a required job that never ran (a - # future `if:` evaluating false, or a needs-skip from an upstream failure) - # must NOT pass the gate silently. - if echo "$results" | grep -qE '"(failure|cancelled|skipped)"'; then - echo "Quality gate failed. Job results: $results" - exit 1 + # `skipped` counts as a failure too: a required job that never ran (an + # `if:` evaluating false, or a needs-skip from an upstream failure) must + # NOT pass the gate silently. EXCEPTION: on the weekly schedule the + # deterministic jobs are skipped BY DESIGN (their `github.event_name != + # 'schedule'` gate), so only tolerate `skipped` there. + if [ "${{ github.event_name }}" = "schedule" ]; then + bad='"(failure|cancelled)"' + else + bad='"(failure|cancelled|skipped)"' fi - echo "All quality gates passed." - - # ───────────────────────────────────────────────────────────────────────────── - # CI Complete — single required status check covering the whole pipeline. - # Set "CI Complete" as the required check in branch protection settings. - # ───────────────────────────────────────────────────────────────────────────── - ci-complete: - name: CI Complete - runs-on: ubuntu-24.04 - timeout-minutes: 5 - needs: [quality-gate] - if: always() - steps: - - name: Check pipeline passed - run: | - results='${{ toJson(needs.*.result) }}' - if echo "$results" | grep -qE '"(failure|cancelled|skipped)"'; then - echo "CI pipeline failed. Job results: $results" + if echo "$results" | grep -qE "$bad"; then + echo "CI gate failed. Job results: $results" exit 1 fi - echo "All CI stages complete." + echo "All CI gates passed." diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d3aa638..e93b4cd 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -7,17 +7,23 @@ name: CodeQL # Advanced setup: GitHub's "default" code-scanning setup must be OFF in repo # settings (Settings → Code security → Code scanning → Advanced) or Analyze errors. +# Triggers are scoped to the ONLY paths this analysis reads (the `actions` +# language surface below + its config): a PR touching only shell scripts or the +# Dockerfile cannot produce different CodeQL results, and previously still ran a +# full ~1.5-3 min analysis. Safe because "CI Complete" (in ci.yml), not CodeQL, +# is the required branch-protection check — a skipped run can't deadlock merges. +# The weekly schedule still covers query-pack updates on an unchanged repo. on: push: branches: [main] - paths-ignore: - - "docs/**" - - "*.md" + paths: + - ".github/workflows/**" + - ".github/codeql/**" pull_request: branches: [main] - paths-ignore: - - "docs/**" - - "*.md" + paths: + - ".github/workflows/**" + - ".github/codeql/**" schedule: - cron: "0 3 * * 1" # weekly, Monday 03:00 UTC — full scan even without code changes diff --git a/CHANGELOG.md b/CHANGELOG.md index 5211502..b414818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ All notable changes to LockBox are documented here. The format is based on [`VERSION`](VERSION); `sync.sh` stamps it (with each canonical file's SHA-256) into every vendored copy, so a baked container self-identifies its egress-lock generation. +## [Unreleased] + +### Changed — performance (TODO.md Pass 8 findings, no behavior changes intended) +- **sync.sh** — each vendored reference copy is generated once up front instead of + once (check) or twice (sync) per file *per target*, cutting 18–36 `gen_vendored`/ + `shasum` runs to 3; this is per-commit latency via the pre-commit `--check` + (~1–2s on macOS). Atomic install + write-verify semantics kept. +- **audit.sh** — the secret scan is a single `git grep --cached -I -nE` over the + whole index instead of ~5–6 processes + a temp file per tracked file; same + index-not-worktree semantics, `file:line`-only reporting. Also fixed: a bare + `id_rsa`/`id_ed25519` at the repo *root* now trips the filename check (the old + `*/id_rsa` pattern required a leading directory). +- **launcher-common.sh** — plugin/statusline staging excludes `.git` at copy time + (`tar --exclude .git`) instead of copying multi-MB git dirs and deleting them, + on the interactive sandbox-start path. +- **Dockerfile** — the image-wide setuid/setgid strip moved before the + frequently-edited COPY block (a routine allowlist/script edit no longer re-pays + a ~10–40s full-filesystem traversal per rebuild); a scoped strip over the + COPY'd paths remains the last layer for the same defense-in-depth coverage. +- **bin/dev** — the proxy-wait folded into the lifecycle `container exec` (one + fewer container-CLI round-trip per launch, ~100–300ms on warm reuse); proxy + polls are 0.2s instead of 1s here and in `post-create.sh`. +- **CI** — the `egress-test` image build uses buildx with the GHA layer cache + + `SKIP_BUILD=1` (cache-hit builds drop from ~4–8 min to seconds); CodeQL + triggers scoped to the workflow/config paths it actually analyzes; the weekly + schedule now runs only the rule-DB–dependent scans (Trivy + gitleaks), with the + gate tolerating the by-design skips; Trivy scans once (SARIF + gating in one + pass); the `quality-gate`/`ci-complete` pair collapsed into a single + "CI Complete" job. + ## [0.1.0] — 2026-07-07 First tagged release. Consolidates the 2026-07-07 hardening pass (the research diff --git a/TODO.md b/TODO.md index ec31e22..c5fea2f 100644 --- a/TODO.md +++ b/TODO.md @@ -305,9 +305,9 @@ Findings are appended per sub-pass below as they complete. #### Pass 8a — Core shell tooling (audit.sh, sync.sh, paths.sh, launcher-common.sh, init-firewall.sh, pre-commit, Makefile, egress-smoke) -- [ ] **[P2]** `sync.sh:167-198` — The regenerated reference vendored copy is target-independent (it depends only on the canonical file's content, version, and hash), yet `gen_vendored` is re-run inside the per-target loop: in `--check` mode once per file per target (6 targets × 3 files = 18 runs, lines 170-177), and in sync mode **twice** per file per target (write at line 194 + verify at line 195 = 36 runs). Each run spawns `mktemp`, a `dirname` subshell, and `sha256_of` — and on the macOS host `sha256_of` falls back to `shasum`, a Perl script with ~40-60ms startup each. That is 18-36 shasum invocations where 3 suffice. *Impact:* roughly 1-2s of avoidable latency per run of `sync.sh` or `sync.sh --check` on macOS — and `--check` runs on **every commit** via `.githooks/pre-commit`, so this is per-commit latency. *Fix:* before the target loop, generate each canonical file's reference copy once into a scratch temp (one `gen_vendored` per file, 3 total); in check mode `cmp` that reference against each target's copy; in sync mode `cp` the reference into place (with the right mode) and `cmp` for the write-verify. Hash memoization alone (cache `sha256_of` per canonical path) would also capture most of the win with a smaller diff. -- [ ] **[P3]** `audit.sh:58-87` — Distinct from the already-fixed per-pattern-grep issue: the scan still spawns ~5-6 processes **per tracked file** (`mktemp`, `git show`, `grep -Iq`, `grep -nE | cut`, `rm`) and writes every staged blob to a temp file that is then read up to 3 times (binary probe at line 74, secret scan at line 78, and the `.pem` grep at line 71). With 36 tracked files that is ~200 fork/execs plus 36 temp-file write/read cycles on every commit, scaling linearly with repo growth; on macOS (slow fork) this is a few hundred ms of the pre-commit gate. *Fix:* do the whole secret scan in one process with `git grep -I -nE "$SECRET_RE" --cached` (searches the index directly — same semantics as `git show :` per file — skips binaries via `-I`, no temp files), post-processing its `file:line:` output for the file:line-only report; keep the filename checks (`id_rsa`/`.key`) as a loop over `git ls-files` output, extracting blobs only for `*.pem` files (typically zero). -- [ ] **[P3]** `launcher-common.sh:31-33` — `sandbox_stage_claude_config` copies `plugins/` and `statusline/` wholesale with `cp -a` (line 31) **including** their `.git` directories, then deletes those `.git` dirs with `find ... -exec rm -rf` (line 33). Plugin/marketplace checkouts are git clones whose `.git` can be many MB, so every launcher start pays full copy I/O for data that is immediately deleted, on the interactive sandbox-start path. *Fix:* exclude at copy time, e.g. for the `plugins`/`statusline` entries use `tar -C "$src" --exclude .git -cf - "$item" | tar -C "$dst/dot-claude" -xf -` (bsdtar on macOS and GNU tar both support `--exclude`; bash-3.2 safe), keeping `cp -a` for the plain-file items. +- [x] **[P2]** — DONE 2026-07-12: reference copies are generated ONCE into a scratch `REF_DIR` before the target loop (one `gen_vendored`/`sha256_of` per canonical file); check mode `cmp`s against the reference, sync mode installs it via same-dir temp + `mv` (atomicity kept) and `cmp`s for the write-verify. `sync.sh:167-198` — The regenerated reference vendored copy is target-independent (it depends only on the canonical file's content, version, and hash), yet `gen_vendored` is re-run inside the per-target loop: in `--check` mode once per file per target (6 targets × 3 files = 18 runs, lines 170-177), and in sync mode **twice** per file per target (write at line 194 + verify at line 195 = 36 runs). Each run spawns `mktemp`, a `dirname` subshell, and `sha256_of` — and on the macOS host `sha256_of` falls back to `shasum`, a Perl script with ~40-60ms startup each. That is 18-36 shasum invocations where 3 suffice. *Impact:* roughly 1-2s of avoidable latency per run of `sync.sh` or `sync.sh --check` on macOS — and `--check` runs on **every commit** via `.githooks/pre-commit`, so this is per-commit latency. *Fix:* before the target loop, generate each canonical file's reference copy once into a scratch temp (one `gen_vendored` per file, 3 total); in check mode `cmp` that reference against each target's copy; in sync mode `cp` the reference into place (with the right mode) and `cmp` for the write-verify. Hash memoization alone (cache `sha256_of` per canonical path) would also capture most of the win with a smaller diff. +- [x] **[P3]** — DONE 2026-07-12: secret scan is now one `git grep --cached -I -nE` over the whole index (file:line via `cut`); filename checks loop over `git ls-files` only, blob extraction only for `*.pem`. Also fixed en passant: bare `id_rsa`/`id_ed25519` at the repo ROOT now match (the old `*/id_rsa` pattern required a leading dir). `audit.sh:58-87` — Distinct from the already-fixed per-pattern-grep issue: the scan still spawns ~5-6 processes **per tracked file** (`mktemp`, `git show`, `grep -Iq`, `grep -nE | cut`, `rm`) and writes every staged blob to a temp file that is then read up to 3 times (binary probe at line 74, secret scan at line 78, and the `.pem` grep at line 71). With 36 tracked files that is ~200 fork/execs plus 36 temp-file write/read cycles on every commit, scaling linearly with repo growth; on macOS (slow fork) this is a few hundred ms of the pre-commit gate. *Fix:* do the whole secret scan in one process with `git grep -I -nE "$SECRET_RE" --cached` (searches the index directly — same semantics as `git show :` per file — skips binaries via `-I`, no temp files), post-processing its `file:line:` output for the file:line-only report; keep the filename checks (`id_rsa`/`.key`) as a loop over `git ls-files` output, extracting blobs only for `*.pem` files (typically zero). +- [x] **[P3]** — DONE 2026-07-12: `statusline`/`plugins` are staged via `tar --exclude .git -cf - | tar -xf -` (`.git` never copied); plain items keep `cp -a`. `launcher-common.sh:31-33` — `sandbox_stage_claude_config` copies `plugins/` and `statusline/` wholesale with `cp -a` (line 31) **including** their `.git` directories, then deletes those `.git` dirs with `find ... -exec rm -rf` (line 33). Plugin/marketplace checkouts are git clones whose `.git` can be many MB, so every launcher start pays full copy I/O for data that is immediately deleted, on the interactive sandbox-start path. *Fix:* exclude at copy time, e.g. for the `plugins`/`statusline` entries use `tar -C "$src" --exclude .git -cf - "$item" | tar -C "$dst/dot-claude" -xf -` (bsdtar on macOS and GNU tar both support `--exclude`; bash-3.2 safe), keeping `cp -a` for the plain-file items. Checked and found clean (Pass 8a — do not re-investigate): @@ -323,8 +323,8 @@ Checked and found clean (Pass 8a — do not re-investigate): #### Pass 8b — Sandbox runtime (Dockerfile, entrypoint, post-*, bin/dev, doctor, verify-pins, perms-fix, squid.conf) -- [ ] **[P3]** `sandbox/.devcontainer/Dockerfile:178-180` — the image-wide setuid/setgid strip (`find / -xdev -type f -perm /6000 -exec chmod -s {} +`) is the last RUN, placed *after* the frequently-edited COPY block (lines 164-176). The deliberate reordering that put cert-gen/pin-gen before the COPYs (known Pass-2 fix) stopped short of this layer: every routine `sync.sh` allowlist or lifecycle-script edit still re-runs a full-filesystem traversal of the ~1.5 GB image (bookworm-slim + build-essential + Node + CPython ≈ low hundreds of thousands of inodes) inside the apple/container builder VM. *Impact:* roughly 10-40s added to every routine rebuild — likely the single most expensive step left on that rebuild path. *Fix:* move the image-wide find to just before the COPY block (after the last package/tool install), and keep last-layer coverage of the COPY'd files with a cheap scoped pass, e.g. `find /usr/local/sbin /usr/local/bin /usr/local/share/dev-sandbox /etc/squid -xdev -type f -perm /6000 -exec chmod -s {} +` — same defense-in-depth guarantee (the COPY'd paths are also already explicitly `chmod 0755`, which clears suid), at near-zero cost. -- [ ] **[P3]** `sandbox/.devcontainer/bin/dev:229-235` — the proxy-wait is a standalone `container exec ... bash -lc` that runs on EVERY launch, immediately followed by a second `container exec` for the lifecycle replay. On the common warm-reuse path the wait succeeds on its first `/dev/tcp` probe, so the entire exec (CLI process + XPC round-trip + login shell reading /etc/profile) is pure overhead, ~100-300ms per launch in the interactive path. On cold starts the `sleep 1` poll granularity (also in `post-create.sh:11-14`) overshoots squid readiness by ~0.5s on average. *Impact:* one redundant container-CLI round-trip on every single `dev` invocation, plus ~0.5-1s avg extra on cold starts. *Fix:* fold the wait loop into the top of the step-6 lifecycle exec (one `container exec` doing wait → post-create/post-start) and use `sleep 0.2` in the poll. Do NOT simply delete the wait — it is the barrier that prevents `post-start.sh`'s `/run/egress-firewall-ok` check (post-start.sh:37-43) from racing the still-running entrypoint on a `container start` of a stopped container; it just doesn't need to be its own exec. (Merging verify-pins too is possible but would sacrifice the deliberate clean-shell `-e BASH_ENV=` isolation at bin/dev:246 — not recommended.) +- [x] **[P3]** — DONE 2026-07-12: image-wide strip moved to just before the COPY block; a scoped strip over `/usr/local/{sbin,bin}`, `/usr/local/share/dev-sandbox`, `/etc/squid` is the new last layer. `sandbox/.devcontainer/Dockerfile:178-180` — the image-wide setuid/setgid strip (`find / -xdev -type f -perm /6000 -exec chmod -s {} +`) is the last RUN, placed *after* the frequently-edited COPY block (lines 164-176). The deliberate reordering that put cert-gen/pin-gen before the COPYs (known Pass-2 fix) stopped short of this layer: every routine `sync.sh` allowlist or lifecycle-script edit still re-runs a full-filesystem traversal of the ~1.5 GB image (bookworm-slim + build-essential + Node + CPython ≈ low hundreds of thousands of inodes) inside the apple/container builder VM. *Impact:* roughly 10-40s added to every routine rebuild — likely the single most expensive step left on that rebuild path. *Fix:* move the image-wide find to just before the COPY block (after the last package/tool install), and keep last-layer coverage of the COPY'd files with a cheap scoped pass, e.g. `find /usr/local/sbin /usr/local/bin /usr/local/share/dev-sandbox /etc/squid -xdev -type f -perm /6000 -exec chmod -s {} +` — same defense-in-depth guarantee (the COPY'd paths are also already explicitly `chmod 0755`, which clears suid), at near-zero cost. +- [x] **[P3]** — DONE 2026-07-12: proxy-wait folded into the top of the step-6 lifecycle exec (one `container exec`, barrier semantics kept); poll is `sleep 0.2` × 150 here and in `post-create.sh`; verify-pins exec left separate (clean-shell isolation), as recommended. `sandbox/.devcontainer/bin/dev:229-235` — the proxy-wait is a standalone `container exec ... bash -lc` that runs on EVERY launch, immediately followed by a second `container exec` for the lifecycle replay. On the common warm-reuse path the wait succeeds on its first `/dev/tcp` probe, so the entire exec (CLI process + XPC round-trip + login shell reading /etc/profile) is pure overhead, ~100-300ms per launch in the interactive path. On cold starts the `sleep 1` poll granularity (also in `post-create.sh:11-14`) overshoots squid readiness by ~0.5s on average. *Impact:* one redundant container-CLI round-trip on every single `dev` invocation, plus ~0.5-1s avg extra on cold starts. *Fix:* fold the wait loop into the top of the step-6 lifecycle exec (one `container exec` doing wait → post-create/post-start) and use `sleep 0.2` in the poll. Do NOT simply delete the wait — it is the barrier that prevents `post-start.sh`'s `/run/egress-firewall-ok` check (post-start.sh:37-43) from racing the still-running entrypoint on a `container start` of a stopped container; it just doesn't need to be its own exec. (Merging verify-pins too is possible but would sacrifice the deliberate clean-shell `-e BASH_ENV=` isolation at bin/dev:246 — not recommended.) Checked and found clean (Pass 8b — do not re-investigate): @@ -341,12 +341,12 @@ Checked and found clean (Pass 8b — do not re-investigate): #### Pass 8c — CI wall-clock / runner minutes (ci.yml, codeql.yml, codeql-config, dependabot) -- [ ] **[P2]** `.github/workflows/ci.yml:165-182` — The `egress-test` job rebuilds the full sandbox image from scratch on every run with zero Docker layer caching (`test/egress-smoke.sh:41` does a plain `docker build`). The Dockerfile installs `build-essential` + squid via apt, downloads Node 24 and python-build-standalone tarballs, and runs `npm install -g @anthropic-ai/claude-code` — its own comments (lines 124-163) are explicitly layer-ordered so that routine changes only touch the final cheap COPY layers, but CI throws that ordering away by never persisting cache. *Impact:* realistically 4-8 min of build time per CI run (every PR push, main push, and weekly schedule); this is the long pole of the pipeline and the bulk of its runner minutes. *Fix:* the script already supports `SKIP_BUILD=1` — add `docker/setup-buildx-action` plus a build step using the GHA cache backend (`docker buildx build --load --cache-from type=gha --cache-to type=gha,mode=max -t dev-sandbox:ci sandbox/.devcontainer`), then run `RUNTIME=docker IMAGE=dev-sandbox:ci SKIP_BUILD=1 ./test/egress-smoke.sh`. Cache-hit builds drop to seconds for the common case where only the COPY'd scripts/allowlist changed. -- [ ] **[P3]** `.github/workflows/codeql.yml:10-20` — CodeQL analyzes only the `actions` language (the matrix at line 45 and the header comment both say the workflows are the *entire* analyzable surface), yet push/PR triggers fire on every non-docs change. A PR touching only shell scripts or the Dockerfile runs a full CodeQL job that cannot produce different results. *Impact:* ~1.5-3 wasted minutes per non-workflow PR run and per main push — likely the majority of runs. *Fix:* replace `paths-ignore` with `paths: [".github/workflows/**", ".github/codeql/**"]` on both `push` and `pull_request`; the weekly schedule (line 22) already covers query-pack updates. Safe because "CI Complete" (not CodeQL) is the required branch-protection check, so a skipped run can't deadlock merges. -- [ ] **[P3]** `.github/workflows/ci.yml:33-36` — The weekly schedule's stated purpose (comment, lines 34-35) is re-scanning against updated Trivy/gitleaks rule DBs, but `schedule` triggers all eight jobs, including the expensive `egress-test` boot and the deterministic `shell`/`secrets`/`vendored`/`dockerfile`/`squid` jobs whose output cannot change on a dormant repo. *Impact:* roughly 8-14 wasted runner-minutes per week (~35-60/month), dominated by egress-test; on a low-traffic repo the schedule may be most of total CI spend. *Fix:* add `if: github.event_name != 'schedule'` to the six deterministic jobs, and make `quality-gate` tolerate their `skipped` result when `github.event_name == 'schedule'` (e.g. branch the grep pattern on event name) so the gate's skipped-equals-failure invariant still holds for PR/push runs. -- [ ] **[P3]** `.github/workflows/ci.yml:203-223` — The Trivy misconfig scan runs twice back-to-back over identical inputs (same scan-type/scan-ref/scanners/severity), once for the gating table output and once to produce SARIF. *Impact:* ~20-40s duplicated per run (second action install + full re-scan; the checks bundle is at least cached under `~/.cache/trivy` between steps). *Fix:* single scan with `format: sarif`, `output: trivy-results.sarif`, `exit-code: "1"` — Trivy writes the SARIF before applying the exit code — and keep the `if: always()` upload step; gating and Security-tab upload are both preserved with one scan. -- [ ] **[P3]** `.github/workflows/ci.yml:236-284` — `quality-gate` and `ci-complete` are two serially chained jobs doing the same aggregation; `ci-complete` needs only `[quality-gate]` and re-runs an identical grep over one result. Each is a full fresh-runner allocation. *Impact:* one extra runner boot serialized onto the critical path of every run (~20-40s added to time-to-green for the required check) plus ~1 extra billable job-minute per run if the repo is private. *Fix:* collapse them — rename `quality-gate` to "CI Complete" (keeping the full needs list and the skipped-counts-as-failure check) and delete the second job; branch protection continues to reference the same check name. -- [ ] **[P3]** `.github/workflows/ci.yml:151-154` — The `squid` job runs `sudo apt-get update` + installs `squid-openssl openssl` from the network on every run with no apt package caching. *Impact:* ~25-50s per run (apt index refresh is the bulk; the packages themselves are small). *Fix:* use `awalsh128/cache-apt-pkgs-action` (pinned by SHA, per repo convention) with `packages: squid-openssl openssl`, or at minimum restrict the update (`apt-get update -o Dir::Etc::sourcelist=...`) — cache-hit runs skip the network entirely. +- [x] **[P2]** — DONE 2026-07-12: `docker/setup-buildx-action` + `docker/build-push-action` (both SHA-pinned) build `dev-sandbox:ci` with `cache-from/to: type=gha,mode=max` and `load: true`; the smoke test runs with `SKIP_BUILD=1`. `.github/workflows/ci.yml:165-182` — The `egress-test` job rebuilds the full sandbox image from scratch on every run with zero Docker layer caching (`test/egress-smoke.sh:41` does a plain `docker build`). The Dockerfile installs `build-essential` + squid via apt, downloads Node 24 and python-build-standalone tarballs, and runs `npm install -g @anthropic-ai/claude-code` — its own comments (lines 124-163) are explicitly layer-ordered so that routine changes only touch the final cheap COPY layers, but CI throws that ordering away by never persisting cache. *Impact:* realistically 4-8 min of build time per CI run (every PR push, main push, and weekly schedule); this is the long pole of the pipeline and the bulk of its runner minutes. *Fix:* the script already supports `SKIP_BUILD=1` — add `docker/setup-buildx-action` plus a build step using the GHA cache backend (`docker buildx build --load --cache-from type=gha --cache-to type=gha,mode=max -t dev-sandbox:ci sandbox/.devcontainer`), then run `RUNTIME=docker IMAGE=dev-sandbox:ci SKIP_BUILD=1 ./test/egress-smoke.sh`. Cache-hit builds drop to seconds for the common case where only the COPY'd scripts/allowlist changed. +- [x] **[P3]** — DONE 2026-07-12: `paths-ignore` replaced with `paths: [".github/workflows/**", ".github/codeql/**"]` on both push and pull_request; weekly schedule unchanged. `.github/workflows/codeql.yml:10-20` — CodeQL analyzes only the `actions` language (the matrix at line 45 and the header comment both say the workflows are the *entire* analyzable surface), yet push/PR triggers fire on every non-docs change. A PR touching only shell scripts or the Dockerfile runs a full CodeQL job that cannot produce different results. *Impact:* ~1.5-3 wasted minutes per non-workflow PR run and per main push — likely the majority of runs. *Fix:* replace `paths-ignore` with `paths: [".github/workflows/**", ".github/codeql/**"]` on both `push` and `pull_request`; the weekly schedule (line 22) already covers query-pack updates. Safe because "CI Complete" (not CodeQL) is the required branch-protection check, so a skipped run can't deadlock merges. +- [x] **[P3]** — DONE 2026-07-12: the six deterministic jobs carry `if: github.event_name != 'schedule'`; the (now single) gate job branches its grep pattern on event name so `skipped` still fails PR/push runs but is tolerated on schedule. `.github/workflows/ci.yml:33-36` — The weekly schedule's stated purpose (comment, lines 34-35) is re-scanning against updated Trivy/gitleaks rule DBs, but `schedule` triggers all eight jobs, including the expensive `egress-test` boot and the deterministic `shell`/`secrets`/`vendored`/`dockerfile`/`squid` jobs whose output cannot change on a dormant repo. *Impact:* roughly 8-14 wasted runner-minutes per week (~35-60/month), dominated by egress-test; on a low-traffic repo the schedule may be most of total CI spend. *Fix:* add `if: github.event_name != 'schedule'` to the six deterministic jobs, and make `quality-gate` tolerate their `skipped` result when `github.event_name == 'schedule'` (e.g. branch the grep pattern on event name) so the gate's skipped-equals-failure invariant still holds for PR/push runs. +- [x] **[P3]** — DONE 2026-07-12: single scan with `format: sarif`, `output: trivy-results.sarif`, `exit-code: "1"`; the `if: always()` SARIF upload kept. `.github/workflows/ci.yml:203-223` — The Trivy misconfig scan runs twice back-to-back over identical inputs (same scan-type/scan-ref/scanners/severity), once for the gating table output and once to produce SARIF. *Impact:* ~20-40s duplicated per run (second action install + full re-scan; the checks bundle is at least cached under `~/.cache/trivy` between steps). *Fix:* single scan with `format: sarif`, `output: trivy-results.sarif`, `exit-code: "1"` — Trivy writes the SARIF before applying the exit code — and keep the `if: always()` upload step; gating and Security-tab upload are both preserved with one scan. +- [x] **[P3]** — DONE 2026-07-12: collapsed into one `ci-complete` job named "CI Complete" carrying the full needs list + skipped-counts-as-failure check (branch protection keeps the same check name). `.github/workflows/ci.yml:236-284` — `quality-gate` and `ci-complete` are two serially chained jobs doing the same aggregation; `ci-complete` needs only `[quality-gate]` and re-runs an identical grep over one result. Each is a full fresh-runner allocation. *Impact:* one extra runner boot serialized onto the critical path of every run (~20-40s added to time-to-green for the required check) plus ~1 extra billable job-minute per run if the repo is private. *Fix:* collapse them — rename `quality-gate` to "CI Complete" (keeping the full needs list and the skipped-counts-as-failure check) and delete the second job; branch protection continues to reference the same check name. +- [~] **[P3]** — ATTEMPTED, REVERTED 2026-07-13: `awalsh128/cache-apt-pkgs-action` (SHA-pinned v1.6.3) triggers a GitHub Actions **startup_failure** — the whole CI run fails to start (no jobs execute) and GitHub surfaces no parse error via API. Isolated by bisecting the workflow on throwaway branches: only this step fails, and it still fails with the `version` input quoted and with every nested action SHA verified to resolve — so the action is incompatible with this repo's Actions environment. The `squid` job keeps plain `apt-get update` + install. `.github/workflows/ci.yml:151-154` — The `squid` job runs `sudo apt-get update` + installs `squid-openssl openssl` from the network on every run with no apt package caching. *Impact:* ~25-50s per run (apt index refresh is the bulk; the packages themselves are small). *Fix:* use `awalsh128/cache-apt-pkgs-action` (pinned by SHA, per repo convention) with `packages: squid-openssl openssl`, or at minimum restrict the update (`apt-get update -o Dir::Etc::sourcelist=...`) — cache-hit runs skip the network entirely. Checked and found clean (Pass 8c — do not re-investigate): @@ -363,6 +363,8 @@ Checked and found clean (Pass 8c — do not re-investigate): #### Pass 8 — suggested working order +**10 OF 11 FINDINGS IMPLEMENTED** (2026-07-12; see the DONE notes on each item above). The 11th — apt caching in the `squid` job via `awalsh128/cache-apt-pkgs-action` — was reverted 2026-07-13 because that action causes a GitHub Actions `startup_failure` in this repo (see that item above); the `squid` job keeps plain `apt-get`. Verified: `audit.sh` (clean + a planted-secret/index-bypass fixture), `EGRESS_SELF_ONLY=1 sync.sh` + `--check` (byte-identical regeneration), `bash -n` + shellcheck on every touched script, a staged-tree fixture for the launcher tar copy, YAML parse of both workflows. The Dockerfile/CI egress build could not be run in the implementation sandbox (registry egress blocked); CI's `egress-test` job covers it. + 1. **CI image-build caching `[8c P2]`** — biggest absolute win (4-8 min per CI run): buildx + GHA cache backend, then `SKIP_BUILD=1` into the smoke test. 2. **`sync.sh` per-target regeneration `[8a P2]`** — per-commit latency via the pre-commit hook (1-2s on macOS); generate each reference copy once before the target loop. 3. Then the P3 one-liners in rough value order: Dockerfile setuid-strip placement (10-40s per rebuild), CodeQL path scoping, schedule-trigger job gating, Trivy single-scan, gate-job collapse, apt caching in the squid job, `audit.sh` single-process `git grep --cached`, bin/dev proxy-wait fold-in, launcher plugin-copy `--exclude .git`. diff --git a/audit.sh b/audit.sh index 4d3d5f0..644c3f6 100755 --- a/audit.sh +++ b/audit.sh @@ -55,37 +55,36 @@ findings=0 # build log / terminal scrollback, widening exposure. report() { echo " ✗ $*"; findings=$((findings + 1)); } -scan_file() { - local f="$1" tmp +echo "== Egress/devcontainer leak audit (git index) ==" + +# Filename checks — a loop over the tracked-file LIST only (NUL-delimited so paths +# with spaces are safe); no blob extraction except for *.pem files (typically zero). +while IFS= read -r -d '' f; do # Private-key FILE by NAME — flag regardless of text/binary (a DER-encoded .key # is binary and would be skipped by the text scan below). + # (bare names too — git ls-files emits root-level files with NO leading dir, + # so `*/id_rsa` alone would miss an id_rsa committed at the repo root) case "$f" in - */id_rsa|*/id_ed25519|*.key) report "private-key file committed: ${f}" ;; + id_rsa|*/id_rsa|id_ed25519|*/id_ed25519|*.key) report "private-key file committed: ${f}" ;; esac - tmp="$(mktemp)" || return 0 - # Staged blob (index version) — see the header for why not the worktree copy. - if ! git show ":$f" >"$tmp" 2>/dev/null; then rm -f "$tmp"; return 0; fi # `.pem` is ALSO the extension for PUBLIC certs / chains / CSRs, so gate it on # content instead of the filename to avoid a false hit on a committed public cert. + # Staged blob (index version) — see the header for why not the worktree copy. case "$f" in - *.pem) grep -q 'PRIVATE KEY' "$tmp" && report "private-key file committed: ${f}" ;; + *.pem) git show ":$f" 2>/dev/null | grep -q 'PRIVATE KEY' && report "private-key file committed: ${f}" ;; esac - # Value-bearing secrets — skip binary blobs, report file:line only. - if grep -Iq . "$tmp"; then - local ln - while IFS= read -r ln; do - [[ -n "$ln" ]] && report "secret pattern at ${f}:${ln}" - done < <(grep -nE "$SECRET_RE" "$tmp" 2>/dev/null | cut -d: -f1) - fi - rm -f "$tmp" -} - -echo "== Egress/devcontainer leak audit (git index) ==" -# All tracked files, NUL-delimited so paths with spaces are safe. -while IFS= read -r -d '' f; do - scan_file "$f" done < <(git ls-files -z) +# Value-bearing secrets — ONE process over the whole index: `git grep --cached` +# searches the staged blobs directly (same semantics as `git show :` per +# file, so the index-not-worktree invariant holds), and -I skips binary blobs. +# This replaces the old per-file mktemp/git-show/grep/rm cycle (~5-6 fork/execs +# per tracked file on every commit). Report file:line only (cut drops the matched +# text — see `report` above for why). +while IFS= read -r hit; do + [[ -n "$hit" ]] && report "secret pattern at ${hit}" +done < <(git grep --cached -I -nE "$SECRET_RE" -- . 2>/dev/null | cut -d: -f1,2) + echo if (( findings == 0 )); then echo "✓ No hardcoded secrets or private keys found in the staged/committed files." diff --git a/launcher-common.sh b/launcher-common.sh index ac68366..eab3100 100644 --- a/launcher-common.sh +++ b/launcher-common.sh @@ -27,10 +27,19 @@ sandbox_stage_claude_config() { dst="$HOME/.claude-sandbox/stage/$profile" rm -rf "$dst/dot-claude"; mkdir -p "$dst/dot-claude" if [[ -n "$src" && -d "$src" ]]; then - for item in settings.json keybindings.json CLAUDE.md agents rules commands skills statusline status-line.sh plugins; do + for item in settings.json keybindings.json CLAUDE.md agents rules commands skills status-line.sh; do [[ -e "$src/$item" ]] && cp -a "$src/$item" "$dst/dot-claude/" 2>/dev/null || true done - find "$dst/dot-claude/statusline" "$dst/dot-claude/plugins" -name .git -type d -prune -exec rm -rf {} + 2>/dev/null || true + # statusline/ and plugins/ hold git CLONES (plugin/marketplace checkouts) whose + # .git dirs can be many MB — exclude them AT COPY TIME rather than cp -a then + # delete, which paid full copy I/O for data immediately discarded, on the + # interactive sandbox-start path. tar --exclude skips the whole .git subtree at + # any depth; works with both bsdtar (macOS) and GNU tar, bash-3.2 safe. + for item in statusline plugins; do + [[ -e "$src/$item" ]] || continue + tar -C "$src" --exclude .git -cf - "$item" 2>/dev/null \ + | tar -C "$dst/dot-claude" -xf - 2>/dev/null || true + done # Rewrite host plugin paths to the container path. Escape the interpolated # values for BRE + the `#` sed delimiter first: a host $HOME/$src containing a # sed metacharacter (or a literal `#`) would otherwise produce a malformed diff --git a/sandbox/.devcontainer/Dockerfile b/sandbox/.devcontainer/Dockerfile index a8f2662..ee05aed 100644 --- a/sandbox/.devcontainer/Dockerfile +++ b/sandbox/.devcontainer/Dockerfile @@ -156,11 +156,18 @@ RUN set -eux; \ chmod 0644 "$pinfile"; \ cat "$pinfile" +# Strip ALL setuid/setgid bits image-wide (defense-in-depth). Placed HERE — after +# the last package/tool install, BEFORE the frequently-edited COPY block below — so +# a routine sync.sh allowlist/script edit does not re-pay this full-filesystem +# traversal (~10-40s inside the builder VM) on every rebuild. The COPY'd files that +# follow are covered by the cheap SCOPED strip in the final layer. +RUN find / -xdev -type f -perm /6000 -exec chmod -s {} + 2>/dev/null || true + # Bake lifecycle + helpers + privileged scripts + squid.conf + the generated -# allowlist LAST (only the setuid strip follows). These are exactly the layers a -# routine sync.sh edit changes, so keeping them after cert-gen + pin-gen means a -# rebuild re-runs only these cheap COPYs + the strip — not the expensive layers -# above. (Run from the IMAGE, never the mounted workspace.) +# allowlist LAST (only the scoped setuid strip follows). These are exactly the +# layers a routine sync.sh edit changes, so keeping them after cert-gen + pin-gen +# means a rebuild re-runs only these cheap COPYs + the scoped strip — not the +# expensive layers above. (Run from the IMAGE, never the mounted workspace.) COPY post-create.sh /usr/local/share/dev-sandbox/post-create.sh COPY post-start.sh /usr/local/share/dev-sandbox/post-start.sh COPY bin/doctor /usr/local/bin/dev-sandbox-doctor @@ -175,9 +182,13 @@ COPY allowlist.txt /etc/squid/allowlist.txt RUN chmod 0755 /usr/local/sbin/egress-firewall /usr/local/sbin/dev-sandbox-perms-fix /usr/local/sbin/dev-sandbox-entrypoint \ && chown root:root /usr/local/sbin/egress-firewall /usr/local/sbin/dev-sandbox-perms-fix /usr/local/sbin/dev-sandbox-entrypoint -# Strip ALL setuid/setgid bits image-wide (defense-in-depth). MUST be the last RUN -# so it covers every file COPY'd above. -RUN find / -xdev -type f -perm /6000 -exec chmod -s {} + 2>/dev/null || true +# Scoped setuid/setgid strip over exactly the paths the COPY block above writes — +# keeps the last-layer coverage the old image-wide strip provided (defense-in-depth; +# the COPY'd scripts are also explicitly chmod 0755, which clears suid) at near-zero +# cost instead of a full-filesystem find. MUST stay the last RUN so it covers every +# file COPY'd above; extend the path list if a COPY ever targets a new directory. +RUN find /usr/local/sbin /usr/local/bin /usr/local/share/dev-sandbox /etc/squid \ + -xdev -type f -perm /6000 -exec chmod -s {} + 2>/dev/null || true WORKDIR /workspaces/project diff --git a/sandbox/.devcontainer/bin/dev b/sandbox/.devcontainer/bin/dev index 3d04aaf..044317e 100755 --- a/sandbox/.devcontainer/bin/dev +++ b/sandbox/.devcontainer/bin/dev @@ -225,12 +225,20 @@ CID="$NAME" # keeps it WARM for instant reuse. (F: orphaned-container on early abort) sandbox_install_autosync_trap "dev" "$CID" "${DEV_SANDBOX_AUTOSYNC:-0}" "${DEV_SANDBOX_STOP_ON_EXIT:-1}" -# Wait for the egress proxy (entrypoint applies firewall → squid). -container exec "$NAME" bash -lc 'for i in $(seq 1 30); do (exec 3<>/dev/tcp/127.0.0.1/3128) 2>/dev/null && break; sleep 1; done' || true - -# --- 6) Replay lifecycle as dev (post-create once, post-start every start) ---- +# --- 6) Wait for the egress proxy, then replay lifecycle as dev --------------- +# (post-create once, post-start every start). The proxy-wait (entrypoint applies +# firewall → squid) runs at the TOP of this exec rather than as its own +# `container exec`: on the common warm-reuse path the first probe succeeds +# immediately, so a separate exec paid a full container-CLI + XPC round-trip +# (~100-300ms) on every launch just to find that out. It is still a required +# BARRIER — on `container start` of a stopped container it keeps post-start.sh's +# /run/egress-firewall-ok check from racing the still-running entrypoint — it just +# doesn't need to be its own exec. 0.2s poll, 150 tries ≈ the same 30s budget. +# (A probe failure inside `... && break` does not trip set -e; a genuinely absent +# proxy is caught by post-start.sh's firewall-sentinel check.) container exec --user dev "$NAME" bash -lc ' set -e + for _ in $(seq 1 150); do (exec 3<>/dev/tcp/127.0.0.1/3128) 2>/dev/null && break; sleep 0.2; done [[ -f /home/dev/.postcreate-done ]] || { bash /usr/local/share/dev-sandbox/post-create.sh && touch /home/dev/.postcreate-done; } bash /usr/local/share/dev-sandbox/post-start.sh' diff --git a/sandbox/.devcontainer/launcher-common.sh b/sandbox/.devcontainer/launcher-common.sh index 832b48c..df44ac9 100644 --- a/sandbox/.devcontainer/launcher-common.sh +++ b/sandbox/.devcontainer/launcher-common.sh @@ -27,10 +27,19 @@ sandbox_stage_claude_config() { dst="$HOME/.claude-sandbox/stage/$profile" rm -rf "$dst/dot-claude"; mkdir -p "$dst/dot-claude" if [[ -n "$src" && -d "$src" ]]; then - for item in settings.json keybindings.json CLAUDE.md agents rules commands skills statusline status-line.sh plugins; do + for item in settings.json keybindings.json CLAUDE.md agents rules commands skills status-line.sh; do [[ -e "$src/$item" ]] && cp -a "$src/$item" "$dst/dot-claude/" 2>/dev/null || true done - find "$dst/dot-claude/statusline" "$dst/dot-claude/plugins" -name .git -type d -prune -exec rm -rf {} + 2>/dev/null || true + # statusline/ and plugins/ hold git CLONES (plugin/marketplace checkouts) whose + # .git dirs can be many MB — exclude them AT COPY TIME rather than cp -a then + # delete, which paid full copy I/O for data immediately discarded, on the + # interactive sandbox-start path. tar --exclude skips the whole .git subtree at + # any depth; works with both bsdtar (macOS) and GNU tar, bash-3.2 safe. + for item in statusline plugins; do + [[ -e "$src/$item" ]] || continue + tar -C "$src" --exclude .git -cf - "$item" 2>/dev/null \ + | tar -C "$dst/dot-claude" -xf - 2>/dev/null || true + done # Rewrite host plugin paths to the container path. Escape the interpolated # values for BRE + the `#` sed delimiter first: a host $HOME/$src containing a # sed metacharacter (or a literal `#`) would otherwise produce a malformed @@ -229,6 +238,6 @@ sandbox_git_ro_mounts() { GIT_RO_MOUNTS+=(-v "$src:$ws/${hp}:ro") } -# ─── vendored by LockBox v0.1.0 · canonical sha256:4f6a69b23c560153781fbb3c01d07f28de976f91ffdabd286fa33663e6438b7c ─── +# ─── vendored by LockBox v0.1.0 · canonical sha256:a1007d62bd10e3aff6869628ddd81452ea992f83756e3cc78de4935c92bcac64 ─── # Generated from the canonical source by LockBox/sync.sh — DO NOT EDIT HERE. # Edit LockBox/launcher-common.sh and re-run ./sync.sh. diff --git a/sandbox/.devcontainer/post-create.sh b/sandbox/.devcontainer/post-create.sh index d159b35..6b4d1f5 100755 --- a/sandbox/.devcontainer/post-create.sh +++ b/sandbox/.devcontainer/post-create.sh @@ -8,9 +8,10 @@ set -euo pipefail # Wait for the egress proxy (started by the root entrypoint) before anything that # might touch the network. echo "[post-create] Waiting for egress proxy on 127.0.0.1:3128..." -for _ in $(seq 1 30); do +# 0.2s poll: a 1s granularity overshot squid readiness by ~0.5s on average. +for _ in $(seq 1 150); do (exec 3<>/dev/tcp/127.0.0.1/3128) 2>/dev/null && break - sleep 1 + sleep 0.2 done # --- Seed ~/.claude from the SANITIZED stage the host launcher produced -------- diff --git a/sync.sh b/sync.sh index ae0dcb2..a16d993 100755 --- a/sync.sh +++ b/sync.sh @@ -155,6 +155,19 @@ while IFS= read -r _vf || [[ -n "$_vf" ]]; do done < "$HERE/vendored-files.txt" (( ${#VENDORED[@]} > 0 )) || { echo "sync: ERROR no vendored files listed in $HERE/vendored-files.txt" >&2; exit 1; } +# A vendored reference copy is TARGET-INDEPENDENT (canonical content + version + +# canonical SHA-256), so generate each one ONCE up front. Regenerating inside the +# target loop ran gen_vendored — and its sha256_of, a ~40-60ms Perl `shasum` startup +# on macOS — once per file per target in check mode and twice per file per target +# in sync mode (write + verify): 18-36 runs where 3 suffice, paid on EVERY commit +# via the .githooks/pre-commit `--check`. The loops below cmp/cp against these. +REF_DIR="$(mktemp -d)" || { echo "sync: ERROR mktemp -d failed" >&2; exit 1; } +trap 'rm -rf "$REF_DIR"' EXIT +for f in "${VENDORED[@]}"; do + [[ -f "$HERE/$f" ]] || continue + gen_vendored "$HERE/$f" "$REF_DIR/$f" +done + synced=0 skipped=0 drift=0 checked=0 for dst in "${EGRESS_DEVCONTAINERS[@]}"; do if [[ ! -d "$dst" ]]; then @@ -168,12 +181,10 @@ for dst in "${EGRESS_DEVCONTAINERS[@]}"; do # Verify-only: every vendored copy + the generated allowlist must already # match what sync WOULD write; any difference is drift (hand-edit / stale). for f in "${VENDORED[@]}"; do - [[ -f "$HERE/$f" ]] || continue - vtmp="$(mktemp)"; gen_vendored "$HERE/$f" "$vtmp" - if ! cmp -s "$vtmp" "$dst/$f"; then + [[ -f "$REF_DIR/$f" ]] || continue + if ! cmp -s "$REF_DIR/$f" "$dst/$f"; then echo "DRIFT: $dst/$f differs from canonical $f (content or provenance stamp)" >&2; drift=$((drift + 1)) fi - rm -f "$vtmp" done tmp="$(mktemp)"; gen_allowlist "$dst" "$project" "$tmp" if ! cmp -s "$tmp" "$dst/allowlist.txt"; then @@ -184,17 +195,19 @@ for dst in "${EGRESS_DEVCONTAINERS[@]}"; do continue fi - # Sync: write canonical content + provenance stamp, then VERIFY it landed by - # regenerating and cmp'ing (a silently short write would otherwise leave a - # stale/partial egress file in place). init-firewall.sh is executable (it runs as - # /usr/local/sbin/egress-firewall); the others are 0644. + # Sync: install the pre-generated reference copy atomically (same-dir temp + mv, + # so an interrupt never leaves a truncated egress file), then VERIFY it landed by + # cmp'ing the installed file against the reference (a silently short write would + # otherwise leave a stale/partial egress file in place). init-firewall.sh is + # executable (it runs as /usr/local/sbin/egress-firewall); the others are 0644. for f in "${VENDORED[@]}"; do - [[ -f "$HERE/$f" ]] || continue + [[ -f "$REF_DIR/$f" ]] || continue mode=0644; if [[ "$f" == init-firewall.sh ]]; then mode=0755; fi - gen_vendored "$HERE/$f" "$dst/$f" "$mode" - vtmp="$(mktemp)"; gen_vendored "$HERE/$f" "$vtmp" - cmp -s "$vtmp" "$dst/$f" || { echo "sync: ERROR vendored write verify failed for $dst/$f" >&2; rm -f "$vtmp"; exit 1; } - rm -f "$vtmp" + vtmp="$(mktemp "$dst/.vendored.XXXXXX")" || { echo "sync: ERROR mktemp failed in $dst" >&2; exit 1; } + cp "$REF_DIR/$f" "$vtmp" + chmod "$mode" "$vtmp" + mv "$vtmp" "$dst/$f" + cmp -s "$REF_DIR/$f" "$dst/$f" || { echo "sync: ERROR vendored write verify failed for $dst/$f" >&2; exit 1; } done gen_allowlist "$dst" "$project" echo "synced + generated allowlist ($(domains "$dst/allowlist.txt" | wc -l | tr -d ' ') hosts) -> $dst"