Repository navigation
fix(cloud-environment): pages-publish reads config only from Key Vault - #761
Conversation
The ~/.config/pages-publish/config override could name any vault secret and any https endpoint, and Bash(pages-publish *) is pre-approved, so any process able to write that file could send any vault secret to any host with no prompt. Config now comes only from the Key Vault secret pages-publish-config, whose writes are permission-gated and audited. - pages-publish: drop the passwd-home file lookup; fail closed (exit 5) when vault-exec is missing or the read fails. The header now says PATH and TMPDIR still matter instead of claiming run-time variables are ignored. - setup.sh: remove the dead PAGES_PUBLISH_* config writer and the RENDERED_VIEWS_MD writer (setup never receives environment variables; the rendered-views-sync SessionStart hook replaces the latter). - Tests: a valid 0600 config in the passwd home, HOME and XDG_CONFIG_HOME is ignored and the Key Vault value is used (fails before this change). Closes #760 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQPrCTBQPb5b1XtbAH7tMA
…ted mode python3 -c puts the working directory first on sys.path, so a json.py planted where pages-publish runs could forge the config and token values vault-exec returns, the same exfiltration path the removed config file gave. -I drops the working directory and PYTHON* variables. A vault-exec test plants json.py in the working directory and on PYTHONPATH; it fails on the previous script. The trusted-inputs notes in pages-publish and the README now also name BASH_ENV and the user-writable install location, and state the guarantee as "no config-file write changes where secrets go". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQPrCTBQPb5b1XtbAH7tMA
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Claude finished @kyle-sexton's task in 3m 51s —— View job Code review (code-review lane)
Scope note: this repo runs a dedicated security lane ( Findings: none. The removal of the
One non-blocking observation, not raised as a finding because it sits on a line this PR didn't touch: the header comment in Verification note: I was not able to execute |
|
Claude finished @kyle-sexton's task in 2m 22s —— View job Security review in progress
|
|
claude-security-review has reviewed this pull request through 6e1552e; a later push is reviewed from there. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 66190c268d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
claude-review has reviewed this pull request through 6e1552e; a later push is reviewed from there. |
Bump SCRIPT_VERSION to 2026-10-10.2 so the completion stamp shows a rebuilt snapshot carries the Key Vault-only pages-publish. RENDERED_VIEWS_MD and the six PAGES_PUBLISH_* config variables stay removed: claude.ai passes environment variables to the session, never to the setup script, so they never reached a cloud cache build. setup.sh now logs one WARN per retired variable still set, naming its replacement (Key Vault secret rendered-views-md through the rendered-views-sync hook, or Key Vault secret pages-publish-config) and never the value; the README documents the retirement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQPrCTBQPb5b1XtbAH7tMA
|
Claude finished @kyle-sexton's task in 2m 57s —— View job Security review (incremental)
Findings: none. This commit (
Checked specifically for the things that would matter here:
A |
|
Claude finished @kyle-sexton's task in 1m 29s —— View job Code review (code-review lane)
Scope: per this lane's incremental instructions, only the diff since Findings: none.
Verification note: I was not able to execute |
No related issue: follow-up to #706 re-adding pages-publish with admission evidence ## Summary Re-adds the `pages-publish` component to the sync manifest for `melodic-software/dotfiles`, which 6554be9 removed because the admitting change lacked the evidence [`docs/component-lifecycle.md`](docs/component-lifecycle.md#admission-evidence) requires. This PR carries that evidence below. `rendered-views-sync` (cloud-only; exits 0 unless `CLAUDE_CODE_REMOTE`) and `vault-exec` (the cloud variant differs from dotfiles' own) stay out. ## Fix Re-applies exactly what 6554be9 removed: - `distribution/sync-manifest.yml`: the `pages-publish` component (`components/cloud-environment/pages-publish` -> `dot_local/bin/executable_pages-publish`), and `pages-publish` in the `melodic-software/dotfiles` managed list. - `components/managed-files-guard/managed-files-guard-fleet.yml`: `dot_local/bin/executable_pages-publish` in the guarded `paths:`, in sorted position. ## Admission evidence Per [`docs/component-lifecycle.md` § Admission evidence](docs/component-lifecycle.md#admission-evidence). - **Live consumer**: the `explain-change` skill in claude-code-plugins (melodic-software/claude-code-plugins#6426, merged 2026-10-07) publishes its digest pages with `pages-publish` when the hosted medium is configured. The dotfiles target is the consumer of the shared bytes here: it materializes them to `~/.local/bin/pages-publish` on local machines. - **Owner and outcome**: owner is the operator (melodic-software). Outcome: one shared page host that any rotated Claude account can publish to, locally and in cloud sessions. Acceptance condition: a stamped page published from a local machine returns a 200 page URL and deletes cleanly, as the cloud check below did. Rollback: drop `pages-publish` from the dotfiles managed list and remove `dot_local/bin/executable_pages-publish` in dotfiles in a coordinated PR, following the distribution lifecycle (deletion is never inferred from deselection). Operating instructions live beside the script in `components/cloud-environment/README.md`. - **Delivery boundary**: standards owns the script (`components/cloud-environment/pages-publish`). Exact materialization through this repository puts the same bytes on local machines via dotfiles. Cloud sessions keep getting them from `components/cloud-environment/setup.sh`. One source file, two delivery paths, no second copy to drift. - **Alternatives and overlap**: built-in Claude Artifacts are bound to one account, so a page published from one rotated account is not reachable from another; that limit is the reason for this work. GitHub Pages and Netlify were considered and rejected by the operator. Overlap: none with existing components. In the claude-code-plugins consumer the Artifact stays the default; the hosted medium is opt-in personal configuration. - **Operational fit**: the PLAN 7.4 cloud live check on 2026-10-10, from a Claude Code cloud session. The `setup.sh`-installed `pages-publish` read its config from the Key Vault secret `pages-publish-config` (#756), published a page at `https://pages.melodicsoftware.com/<id>/` that returned 200 with a `content-security-policy` header and `noindex`, private routing worked, and the page returned 404 after delete. No permission prompt and no classifier denial fired; the allow rule `Bash(pages-publish *)` is in the standards permissions floor (#706). The local dotfiles path was checked the same day; see "Local consumer check (2026-10-10)" below. Requirements: bash 4+, curl, awk, coreutils `realpath`/`stat`; `gitleaks` is used when present. There is no `getent` dependency, so the local path is not limited to Linux and WSL. Credentials: bearer tokens and the Access service-token pair are resolved by `vault-exec` and reach curl only on stdin, never argv. Failure behavior: distinct exit codes (2 usage, 3 not a stamped temp-dir page, 4 credential content, 5 config missing or invalid, 6 upload failed), and the credential scan runs before any network call. - **Upstream health**: first-party script with no third-party upstream; its only runtime dependencies are base OS tools. - **Legal and security fit**: first-party code in this repository, so there is no upstream license to record. The repository declares `"license": "UNLICENSED"` (`package.json`) and ships no LICENSE file. No vendored or downloaded artifact. Provenance and integrity: the sync copies the tracked, clean source bytes and their executable mode exactly (`distribution/sync-manifest.sh` rejects untracked or dirty sources); they reach dotfiles only through the reviewed standards-sync PR; and the managed-files guard blocks hand edits to the destination there. Network boundary: HTTPS only (`--proto =https`) to the two configured upload origins. Security review: a fresh-context review of this diff against `conventions/review/security.md` found no injection and no argv credential exposure. It found one P3 issue in the existing script, tracked in #760: a user-written `~/.config/pages-publish/config` could name any vault secret and any https endpoint, and the floor pre-approves `Bash(pages-publish *)` but not `vault-exec`. The P3 issue (#760) was fixed by #761 before this PR merged: config now comes only from Key Vault, so no file write can redirect secrets. The script landed through #706, #756 and #761; this PR changes no script bytes. - **Update path**: no independent dependency root, so no updater entry. The script changes through standards PRs and reaches dotfiles through the normal sync. - **Verification**: `components/cloud-environment/pages-publish.test.sh` is the behavioral suite and runs in `pr-require-checks.yml`; the managed-files-guard and sync-manifest suites cover the materialization. ## Local consumer check (2026-10-10) PLAN 7.4 local: the dotfiles delivery path on a local WSL2 Linux machine (kernel 6.18, bash), with the exact bytes this sync delivers. - **vault-exec**: rendered from dotfiles `main` at 7ddb14c with `chezmoi --source <fresh worktree> apply ~/.local/bin/vault-exec`. The preview diff was only the name map from melodic-software/dotfiles#1106: +`pages-publish-config` and the four `cloudflare-pages-*` names, -`cloudflare-r2-pages-public`/`-private`, all under `kv-melo-devtools-prod`. This is dotfiles' own `az`-based resolver, not the cloud variant. - **pages-publish**: installed to `~/.local/bin/pages-publish` (mode 0755) from standards `main` at 41282ac, `components/cloud-environment/pages-publish`. Git blob `126013899fd8777cd714b259b9c03fa2d14d1e4d`, matching the blob at that ref and the source this PR syncs; sha256 `d03ecf8decca2df02382ddb1e802abc65c45693f19e3a6b18d1ce93b676ba995`. Once the standards-sync PR lands, chezmoi manages this file with the same bytes. - **Consumer**: explain-change's `publish-hosted.mjs` (the script from melodic-software/claude-code-plugins#6783) published a builder-stamped digest page of claude-code-plugins#6783 with `--repo melodic-software/claude-code-plugins`. The repository visibility lookup returned public, and the page went to `https://pages.melodicsoftware.com/Cr_e5xdZNGSKuNo-NQsSWQ/`, which returned HTTP 200 with `content-security-policy: default-src 'none'; ...; sandbox allow-scripts allow-downloads` and `x-robots-tag: noindex, nofollow`. pages-publish read its config only from the Key Vault secret `pages-publish-config` through the local vault-exec. - **Private routing**: the same page with `--repo melodic-software/architecture` (private) routed to `https://pages-private.melodicsoftware.com/bVYsWtKUIE6evlGNobHc3A/`. An unauthenticated request got HTTP 302 to the Cloudflare Access login (`melodic.cloudflareaccess.com`) with a `www-authenticate: Cloudflare-Access` header and no page content. - **Delete**: `pages-publish --delete <id> --visibility public|private` removed both pages, each with exit 0; the public URL then returned 404. - **Permissions**: no permission prompt and no classifier denial fired for `pages-publish` or `publish-hosted.mjs` in this session. ## Verification Run locally in the worktree: - `bash distribution/sync-manifest.sh validate` -> `Manifest valid: 42 components, 13 targets` - `bash distribution/sync-manifest.test.sh` (after `npm ci --prefix distribution`) -> 336 PASS, 0 FAIL - `bash harness/shell/run-tests.sh components/managed-files-guard/managed-files-guard.test.sh components/cloud-environment/pages-publish.test.sh` -> 2 passed, 0 failed - lefthook pre-commit (editorconfig, typos, gitleaks) passed on the commit ## Related - #706 (pages-publish and the permissions floor allow rule) - #756 (Key Vault config secret `pages-publish-config`) - #758 (rendered-views preference sync) - 6554be9 (the removal this re-applies) - melodic-software/claude-code-plugins#6426 (explain-change hosted medium, the live consumer) - melodic-software/claude-code-plugins#6783 (publish-hosted looks up repository visibility itself; used in the local check) - melodic-software/dotfiles#1106 (local vault-exec map for the pages-publish secrets) - #760 (pages-publish config-override hardening, found in this PR's security review; closed by #761) - #761 (pages-publish reads config only from Key Vault) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01EQPrCTBQPb5b1XtbAH7tMA Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Closes #760
Summary
pages-publishnow reads its config only from the Key Vault secretpages-publish-config. The~/.config/pages-publish/configfile override is gone. That file could name any vault secret and any https endpoint, andBash(pages-publish *)is pre-approved, so any process able to write it could send any vault secret to any host with no prompt. Key Vault writes are permission-gated and audited (kv-audit diagnostic logs). A local operator changes the config withaz keyvault secret setonpages-publish-config. The ready review found a second config-forging path, ajson.pyplanted in the working directory and picked up byvault-exec'spython3 -c; it is closed here too.Fix
components/cloud-environment/pages-publish:load_configreads only Key Vault (stillenv -u VAULT_EXEC_VAULT -u PP_CONFIG, the empty-value check andparse_config). The passwd-home,getent,statand owner/mode logic is removed, which also drops the macOSgetentlimitation. A missingvault-execor a failed read exits 5. The header now says why config comes only from Key Vault, and no longer claims run-time variables are ignored: it namesPATH,TMPDIRandBASH_ENV, and states the guarantee as "no config-file write changes where secrets go" (the two lower-severity notes in fix(cloud-environment): pages-publish config override can send any vault secret to any host #760).components/cloud-environment/vault-exec: the JSON parser runs aspython3 -I -c, so neither the working directory norPYTHON*variables reach its import path. Without it, a plantedjson.pycould return any config value (attacker endpoint, any token secret name) andpages-publishwould send that secret with no prompt.components/cloud-environment/setup.sh: removed the writer that built the config file fromPAGES_PUBLISH_*variables, and the writer that wrote~/.claude/rendered-views.mdfromRENDERED_VIEWS_MD. Both were dead code: claude.ai passes an environment's variables to the session, not to the setup script, and therendered-views-syncSessionStart hook already supplies the rendered-views preference. Therendered-views-syncinstall and hook registration are unchanged.components/cloud-environment/README.md: config, exit-code, trusted-inputs and setup sections updated to match.Verification
pages-publish.test.sh: a valid 0600 config naming a decoy endpoint and token secret, placed in the passwd home (stubgetent),HOMEandXDG_CONFIG_HOME, is ignored, and the upload uses the Key Vault endpoint and token. Run against the pre-fix script, it fails (curl gothttps://decoy.example.test/_uploadandBearer SENTINEL-decoy-token). The config-validation cases (http endpoint, missing key, unknown key, bad secret name, non-KEY=VALUE line, empty secret) now run against the Key Vault value.vault-exec.test.sh: ajson.pyreturningFORGED, planted in the working directory and onPYTHONPATH, is ignored. Run against the pre-fixvault-exec, it fails (got FORGED).bash harness/shell/run-tests.sh components/cloud-environment/pages-publish.test.sh components/cloud-environment/setup.test.sh components/cloud-environment/rendered-views-sync.test.sh components/cloud-environment/vault-exec.test.sh: 4 passed.shellcheck -x,shfmt -d,markdownlint-cli2,typos, and the lefthook pre-commit lanes (editorconfig, typos, markdownlint, gitleaks, shellcheck): clean.~/.config/pages-publish/config, which nothing reads now (the decoy test proves it is ignored).Related
🤖 Generated with Claude Code
https://claude.ai/code/session_01EQPrCTBQPb5b1XtbAH7tMA