Skip to content

Commit 757f3c9

Browse files
Make the cloud gate actually gate, and stop exporting credentials
An external improvement review found the cloud lane's central promise was inert, and it was right. overlay.sh installs .claude/verify.sh and wires the Stop hook, but verify-gate.sh refuses to run a repo's verify.sh without a machine-local trust entry, and a fresh cloud sandbox has none. The gate was installed, wired, and silently skipping on every Stop — in the one lane that exists for cloud work. My own overlay test missed it because it asserted the files landed rather than what the hook decided. That distinction is the lesson. The sandbox setup line now runs `vstack trust` after bootstrapping. Local protection is unchanged: an untrusted repo cloned to your laptop still runs nothing until you type it yourself. This line lives in a file you committed, in a disposable sandbox, for a repo you deliberately dispatched work to. The second finding was mine. install.sh sourced secrets.env into .zshenv with `set -a`, and adding the bash lane extended that to .bashrc and .profile — widening a credential exposure while fixing something else. One token reached every child process of every shell. Nothing needed it; every wrapper in bin/ already loads secrets.env itself. Removing it from the installer only helps new machines, so install.sh now removes the line it previously wrote, matched exactly and backed up first. It leaves any other spelling alone: doctor found one on this machine that predates vstack, and deleting a line from a file you own because it resembles ours would be the same class of mistake as the destructive bugs earlier in this audit. doctor fails if vstack's form returns, and notes yours without failing. Both covered end to end. The cloud case asserts the decision — skip before arming, block after — and scopes TMPDIR per run, because verify-gate.sh caps repeated blocks per session id and a test reusing one reads that cap as the gate having broken. verify.sh 25/25, falsifiability 26/26, matrix 19/19, doctor and drift green.
1 parent a8ffca5 commit 757f3c9

4 files changed

Lines changed: 134 additions & 8 deletions

File tree

bin/doctor

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,28 @@ n=$(ls "$CDIR/agents"/*.md 2>/dev/null | wc -l | tr -d ' ')
270270
[ "$n" -ge "$WANT_AGENTS" ] && ok "subagents ($n)" || bad "subagents" "$n<$WANT_AGENTS"
271271
echo "── secrets ──"
272272
[ -f "$HOME/.config/agents/secrets.env" ] && [ "$(get_perms "$HOME/.config/agents/secrets.env")" = "600" ] && ok "secrets.env (600)" || bad "secrets.env" "missing/perms"
273-
grep -q 'secrets.env' "$HOME/.zshenv" 2>/dev/null && ok "sourced in .zshenv" || bad "zshenv source" "not sourced"
273+
# Asserts the opposite of what it used to. Sourcing secrets.env from a shell rc exports every
274+
# credential to every child process of every shell; the wrappers in bin/ load what they need
275+
# themselves. An install that puts it back is a regression, so this fails on finding it.
276+
# Two different things can put this line in an rc file, and they deserve different answers.
277+
# The `set -a` form is the one install.sh used to write: if it is back, that is a regression in
278+
# this repo and it fails. Any other spelling is a line the user wrote themselves — still a real
279+
# exposure worth naming, but not this tool's to fail over or to delete.
280+
_amb_ours=""; _amb_theirs=""
281+
for _rc in .zshenv .zshrc .bashrc .profile; do
282+
[ -f "$HOME/$_rc" ] || continue
283+
if grep -q 'set -a && \. "$HOME/.config/agents/secrets.env" && set +a' "$HOME/$_rc" 2>/dev/null; then
284+
_amb_ours="$_amb_ours $_rc"
285+
elif grep -q 'agents/secrets.env' "$HOME/$_rc" 2>/dev/null; then
286+
_amb_theirs="$_amb_theirs $_rc"
287+
fi
288+
done
289+
if [ -n "$_amb_ours" ]; then
290+
bad "credentials exported to shells" "vstack's own export is back in:$_amb_ours — re-run ./install.sh, which removes it"
291+
else
292+
ok "vstack exports no credentials to shells"
293+
fi
294+
[ -n "$_amb_theirs" ] && p "your own secrets sourcing" "note:$_amb_theirs sources secrets.env, so every child process of those shells sees your tokens. The wrappers in ~/.config/agents/bin load what they need on their own, so this line is probably not needed."
274295
for w in cloudflare-mcp; do [ -x "$HOME/.config/agents/bin/$w" ] && ok "wrapper $w" || bad "wrapper $w" "missing"; done
275296
echo "── deploy ──"; [ -x "$HOME/.config/agents/bin/deploy-auto.sh" ] && ok "deploy-auto.sh" || bad "deploy-auto.sh" "missing"
276297
echo "── memory ──"; [ -d "$HOME/.claude-mem" ] && ok "claude-mem store" || bad "claude-mem" "missing"

install.sh

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -350,10 +350,36 @@ back "$HOME/.zshenv"
350350
if [ "$DRY" = 0 ] && ! grep -q '>>> claude-parity env >>>' "$HOME/.zshenv" 2>/dev/null; then
351351
cat "$SRC/shell/zshenv.snippet" >> "$HOME/.zshenv"
352352
fi
353-
if [ "$DRY" = 0 ] && ! grep -q 'agents/secrets.env' "$HOME/.zshenv" 2>/dev/null; then
354-
printf '\n[ -f "$HOME/.config/agents/secrets.env" ] && set -a && . "$HOME/.config/agents/secrets.env" && set +a\n' >> "$HOME/.zshenv"
353+
# Credentials are NOT exported into your shells, and any earlier line that did is removed.
354+
#
355+
# Leaving it behind would mean the fix only reached new machines while every existing install
356+
# kept leaking. vstack wrote that line, so vstack takes it out — matched exactly, backed up
357+
# first, and only ever the form this installer produced. A line you wrote yourself that happens
358+
# to mention secrets.env is not touched.
359+
if [ "$DRY" = 0 ]; then
360+
for rc in .zshenv .zshrc .bashrc .profile; do
361+
[ -f "$HOME/$rc" ] || continue
362+
grep -q 'set -a && \. "$HOME/.config/agents/secrets.env" && set +a' "$HOME/$rc" 2>/dev/null || continue
363+
back "$HOME/$rc"
364+
tmp=$(mktemp)
365+
grep -vF '[ -f "$HOME/.config/agents/secrets.env" ] && set -a && . "$HOME/.config/agents/secrets.env" && set +a' "$HOME/$rc" > "$tmp" && cat "$tmp" > "$HOME/$rc"
366+
rm -f "$tmp"
367+
say "removed credential export from ~/$rc (wrappers load their own; backup in $BK)"
368+
done
355369
fi
356370

371+
# Credentials are NOT exported into your shells.
372+
#
373+
# This used to source secrets.env with `set -a` into .zshenv, and then — when the bash lane was
374+
# added — into .bashrc and .profile too, which widened it rather than fixing it. The effect was
375+
# that filling in a Cloudflare token handed it to every child process of every shell: every
376+
# script in every repo you cd into, every package postinstall, every tool you try once.
377+
#
378+
# Nothing needed it. Every wrapper in bin/ already loads secrets.env itself (see
379+
# bin/cloudflare-mcp), which is the correct scope: the process that needs the credential reads
380+
# it, and nothing else sees it. The parity block above stays, because those are CLAUDE_* tuning
381+
# variables, not secrets.
382+
357383
# bash gets the same environment. The wrapper does not travel — claude-parity.zsh is written
358384
# in zsh (whence -p, print -r, local -a) and cannot be sourced by bash — but the env snippet
359385
# and the secrets line are plain POSIX exports, and they are the part that actually changes
@@ -373,9 +399,6 @@ if [ "$DRY" = 0 ]; then
373399
cat "$SRC/shell/zshenv.snippet" >> "$HOME/$rc"
374400
SHELL_LANES="$SHELL_LANES, $rc"
375401
fi
376-
if ! grep -q 'agents/secrets.env' "$HOME/$rc" 2>/dev/null; then
377-
printf '\n[ -f "$HOME/.config/agents/secrets.env" ] && set -a && . "$HOME/.config/agents/secrets.env" && set +a\n' >> "$HOME/$rc"
378-
fi
379402
done
380403
case "${SHELL:-}" in
381404
*zsh) ;;

overlay.sh

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,20 @@ else
124124
# Cloud workspaces start from a bare Linux sandbox with no ~/.claude, so pull vstack in.
125125
# Pinned to a reviewed commit so a compromised repo/account cannot push code into every
126126
# sandbox at once — bump the SHA deliberately when updating vstack.
127+
#
128+
# `vstack trust` arms this repo's .claude/verify.sh for the Stop-hook gate. Without it the
129+
# gate installs and does nothing: verify-gate.sh refuses to execute a repo's verify.sh unless
130+
# a machine-local trust entry matches its hash, and a fresh sandbox has no such entry. The
131+
# gate was inert in the one lane that exists for cloud work — installed, wired, and silently
132+
# skipping on every Stop.
133+
#
134+
# Arming it here rather than in verify-gate.sh keeps the local protection intact. Cloning an
135+
# untrusted repo onto your laptop still runs nothing until you type `vstack trust` yourself.
136+
# This line is different: it lives in a file you committed, in a disposable sandbox, for a
137+
# repo you deliberately dispatched work to. That is the consent, and it is visible in the diff.
138+
#
127139
# Add this repo's own install step (npm ci, uv sync, ...) to the end of this line.
128-
setup = "curl -fsSL https://raw.githubusercontent.com/itsvedantkumar/vstack/__PIN__/bootstrap.sh | bash"
140+
setup = "curl -fsSL https://raw.githubusercontent.com/itsvedantkumar/vstack/__PIN__/bootstrap.sh | bash && \"$HOME/.config/agents/bin/vstack\" trust"
129141
run_mode = "concurrent"
130142
131143
[scripts.run.verify]

tests/install-matrix.sh

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,11 @@ if want bash-only; then
185185
out=$(HOME="$H" SHELL=/bin/bash "$SRC/install.sh" 2>&1); rc=$?
186186
e=""
187187
grep -q 'ENABLE_PROMPT_CACHING_1H' "$H/.bashrc" 2>/dev/null || e="$e; env snippet missing from .bashrc"
188-
grep -q 'agents/secrets.env' "$H/.bashrc" 2>/dev/null || e="$e; secrets not sourced in .bashrc"
188+
# Deliberately the opposite of what this used to assert. The env snippet must reach bash —
189+
# that is the tuning that changes behaviour — but credentials must not, because sourcing
190+
# secrets.env from an rc file hands every token to every child process of every shell. The
191+
# wrappers in bin/ load what they need themselves.
192+
grep -q 'agents/secrets.env' "$H/.bashrc" 2>/dev/null && e="$e; .bashrc exports credentials to every shell"
189193
printf '%s' "$out" | grep -q 'zsh-only' || e="$e; did not say the wrapper is zsh-only"
190194
[ "$rc" = 0 ] && [ -z "$e" ] && ok "bash user gets the env lane" || bad "bash user gets the env lane" "exit=$rc$e"
191195
fi
@@ -558,6 +562,72 @@ if want recover; then
558562
|| bad "re-running converges from a damaged install" "${e#; }"
559563
fi
560564

565+
# --- the cloud lane's gate must actually gate --------------------------------------------------
566+
# The overlay case above proves the files land. It did not prove the gate works, and it did not:
567+
# verify-gate.sh refuses to execute a repo's verify.sh without a machine-local trust entry, and
568+
# a fresh sandbox has none. So the Stop gate was installed, wired, and silently skipping on every
569+
# Stop — inert in the one lane that exists for cloud work.
570+
#
571+
# This walks the whole sandbox sequence: overlay a repo, install vstack into an empty HOME the
572+
# way the pinned setup line does, run the trust step that line now performs, break the repo's
573+
# gate, and require the Stop hook to block. Asserting the decision, not the file list, is the
574+
# difference between testing that something is installed and testing that it works.
575+
if want cloud-gate; then
576+
if ! command -v git >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then
577+
skip "cloud sandbox gate blocks" "git or jq not installed"
578+
else
579+
T="$ROOT/cloud"; H="$ROOT/cloud-home"; mkdir -p "$T/repo" "$H"
580+
git -C "$T/repo" init -q
581+
git -C "$T/repo" config user.email t@example.com; git -C "$T/repo" config user.name t
582+
printf 'x\n' > "$T/repo/f"; git -C "$T/repo" add -A; git -C "$T/repo" commit -qm init
583+
"$SRC/overlay.sh" "$T/repo" >/dev/null 2>&1
584+
e=""
585+
# TOML escapes the quotes, so the line reads ...bin/vstack\" trust. Match on the command
586+
# rather than a quoted fragment.
587+
grep -qE 'bin/vstack.*trust' "$T/repo/.conductor/settings.toml" 2>/dev/null \
588+
|| e="$e; the sandbox setup line does not arm trust"
589+
# the sandbox: empty HOME, vstack installed by the pinned bootstrap, then the trust step
590+
HOME="$H" "$SRC/install.sh" >/dev/null 2>&1
591+
printf '#!/usr/bin/env bash\necho "seeded failure"\nexit 1\n' > "$T/repo/.claude/verify.sh"
592+
chmod +x "$T/repo/.claude/verify.sh"
593+
# before arming, it must skip — that is the local protection working as designed
594+
# TMPDIR is scoped to this run. verify-gate.sh caps repeated blocks per session id in a
595+
# counter file under TMPDIR, so a test that reuses an id eventually gets a silent exit 0 —
596+
# the loop cap doing its job, read as the gate having stopped working.
597+
mkdir -p "$ROOT/cloud-tmp"
598+
d0=$(printf '{"session_id":"c0"}' | env HOME="$H" TMPDIR="$ROOT/cloud-tmp" CLAUDE_PROJECT_DIR="$T/repo" \
599+
bash "$SRC/claude/hooks/verify-gate.sh" 2>/dev/null | jq -r '.decision // "none"' 2>/dev/null)
600+
[ "$d0" = none ] || e="$e; an unarmed repo's gate ran without trust (decision=$d0)"
601+
( cd "$T/repo" && HOME="$H" "$H/.config/agents/bin/vstack" trust >/dev/null 2>&1 )
602+
d1=$(printf '{"session_id":"c1"}' | env HOME="$H" TMPDIR="$ROOT/cloud-tmp" CLAUDE_PROJECT_DIR="$T/repo" \
603+
bash "$SRC/claude/hooks/verify-gate.sh" 2>/dev/null | jq -r '.decision // "none"' 2>/dev/null)
604+
[ "$d1" = block ] || e="$e; after arming, a failing gate did not block (decision=$d1)"
605+
[ -z "$e" ] && ok "cloud sandbox gate blocks after the setup line arms it" \
606+
|| bad "cloud sandbox gate blocks after the setup line arms it" "${e#; }"
607+
fi
608+
fi
609+
610+
# --- credentials must not be ambient ------------------------------------------------------------
611+
# Filling in one token used to hand it to every child process of every shell: every script in
612+
# every repo, every package postinstall, every tool you try once. The bash lane made it worse by
613+
# adding .bashrc and .profile to the list. Every wrapper in bin/ already loads what it needs.
614+
if want no-ambient-secrets; then
615+
H="$ROOT/amb"; mkdir -p "$H"
616+
HOME="$H" "$SRC/install.sh" >/dev/null 2>&1
617+
e=""
618+
for rc in .zshenv .zshrc .bashrc .profile; do
619+
[ -f "$H/$rc" ] || continue
620+
grep -q 'agents/secrets.env' "$H/$rc" 2>/dev/null && e="$e; $rc exports secrets to every shell"
621+
done
622+
# the tuning variables are not secrets and must still be there
623+
grep -q 'ENABLE_PROMPT_CACHING_1H' "$H/.zshenv" 2>/dev/null || e="$e; the parity env block went missing"
624+
# and the wrapper must still be able to reach them itself
625+
grep -q 'secrets.env' "$H/.config/agents/bin/cloudflare-mcp" 2>/dev/null \
626+
|| e="$e; the wrapper no longer loads its own credentials"
627+
[ -z "$e" ] && ok "credentials are not exported into shells" \
628+
|| bad "credentials are not exported into shells" "${e#; }"
629+
fi
630+
561631
echo
562632
printf '%d passed, %d failed' "$PASS" "$FAIL"
563633
[ "$SKIP" -gt 0 ] && printf ', %d skipped' "$SKIP"

0 commit comments

Comments
 (0)