diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1402792..7e1a5b8 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "vstack", - "version": "1.66.0", + "version": "1.67.0", "source": "./claude", "description": "28 skills that fire without a slash command, 14 agents, 15 commands, and the session hook that routes situations to skills. Most skills are ported from pstack and Superpowers \u2014 see claude/skills/ATTRIBUTION.md for per-skill source and license.", "category": "workflow" diff --git a/.claude/verify.sh b/.claude/verify.sh index 7735ae3..00ee5d8 100755 --- a/.claude/verify.sh +++ b/.claude/verify.sh @@ -2739,6 +2739,14 @@ p_errs="" # placeholder paths (claude/skills|agents|commands), third-party evidence (docs/provenance|research), # installed artifacts (.claude/), historical entries (CHANGELOG), and third-party test results # (tests/evals/RESULTS.md). This matches check 12's approach and keeps the two consistent. +# Destinations `vstack overlay .` SEEDS into a TARGET repository, derived from overlay.sh rather +# than listed. README's overlay table names `.github/workflows/security.yml` in its "Lands at" +# column: that is a path in somebody else's repo after the overlay runs, the same category as the +# `.claude/*` exemption below, and holding this tree to it would demand vstack ship every file it +# hands to other people. Derived, so retiring a template retires its exemption with it -- a +# hardcoded list here would outlive the thing it excuses, which is the failure check 20d exists +# for. Leading and trailing spaces so the membership test below cannot match a prefix. +p_seeded=" $(grep -oE '^seed_tmpl [^ ]+ [^ ]+' overlay.sh 2>/dev/null | awk '{print $3}' | tr '\n' ' ')" p_docs=$(git ls-files '*.md' 2>/dev/null | grep -vE '^(docs/(provenance|research)/|claude/(skills|agents|commands)/|\.claude/|CHANGELOG\.md$|tests/evals/RESULTS\.md$)') p_n=$(printf '%s' "$p_docs" | grep -c .) if [ "$p_n" -lt 8 ]; then @@ -2758,6 +2766,7 @@ else # this one. Paths this repository does ship live under claude/, without the dot. .claude/*) continue ;; esac + case "$p_seeded" in *" $ref "*) continue ;; esac p_seen=$((p_seen + 1)) [ -e "$ref" ] && continue [ -e "${d%/*}/$ref" ] && continue @@ -4667,6 +4676,128 @@ else fi fi +# --- 64. the security lane skips a missing scanner and still fails on a real finding ----------- +# The security toolchain shipped as three payload files and a README table, and every part of it +# was prose. Nothing ran claude/security-scan.sh, nothing read the workflow template's action +# pins, and nothing compared the tools README's "Prod-ready gates" table publishes against the +# tools the script actually calls. The failure mode is not that the scan is missing -- it is that +# the scan runs, finds no scanners installed, prints five reassuring lines and exits 0. That is a +# fake green with a per-tool report attached, which reads stronger than no check at all. +# +# Measured in a throwaway git repo, never in this one: the answer here is contaminated by whichever +# scanners this particular machine happens to have installed, so a run on the author's laptop and +# a run in CI would be testing two different scripts. +# +# Lane 1 -- PATH=/usr/bin:/bin, so no scanner is reachable. Every tool must SKIP and the script +# must exit 0. A missing scanner failing the run would make the overlay's gate red on every +# machine that has not installed five binaries, which is how a gate gets switched off. +# Lane 2 -- the same repo with a stub `gitleaks` that reports a finding and exits 1. The script +# must exit 1 and name it. Lane 1 alone is satisfied by a script that can only ever skip, which +# is precisely the shape lane 1 cannot distinguish from a working scan. +# Lane 64b -- the half that is not executable here: the CI workflow's pins and the README table +# that documents which tool runs where. +c64_lbl="the security lane skips a missing scanner and fails on a finding" +c64_sh="claude/security-scan.sh" +c64_wf="claude/security.yml.tmpl" +if [ ! -f "$c64_sh" ]; then + bad "$c64_lbl" "$c64_sh is missing, so overlay.sh ships a verify.sh template that calls a script nobody wrote" +elif [ ! -f "$c64_wf" ]; then + bad "$c64_lbl" "$c64_wf is missing, so the CI half of the lane README publishes does not exist" +else + c64_errs="" + c64_note="" + + # The exec bit as GIT records it, not as this filesystem shows it. A 100644 payload script is + # runnable here (the author chmod'd it once) and unrunnable for everyone who clones, and the + # overlay's own chmod hides that from the only lane that would have caught it. + c64_mode=$(git ls-files -s "$c64_sh" 2>/dev/null | awk '{print $1}') + [ "$c64_mode" = "100755" ] \ + || c64_errs="$c64_errs\n$c64_sh is ${c64_mode:-not tracked by git}, not mode 100755; a stranger's clone gets a file it cannot execute" + + # Skip, not fail, on a host without shellcheck -- check 0 already decides whether this gate may + # run at all without it, and duplicating that judgement here would fail hosts check 0 passes. + if command -v shellcheck >/dev/null 2>&1; then + if ! c64_sc=$(shellcheck -S warning -f gcc "$c64_sh" 2>/dev/null); then + c64_errs="$c64_errs\nshellcheck -S warning on $c64_sh: $(printf '%s' "$c64_sc" | head -5 | tr '\n' ';')" + fi + else + c64_note=", shellcheck not on PATH" + fi + + c64_tmp=$(mktemp -d "${TMPDIR:-/tmp}/vstack-c64.XXXXXX") + git -C "$c64_tmp" init -q >/dev/null 2>&1 + cp "$c64_sh" "$c64_tmp/security-scan.sh" + chmod 755 "$c64_tmp/security-scan.sh" + + # env -i: this shell's PATH carries whatever the developer has installed, and inheriting it is + # the difference between measuring the script and measuring the machine. + c64_out=$(cd "$c64_tmp" && env -i PATH=/usr/bin:/bin HOME="$c64_tmp" TMPDIR="$c64_tmp" \ + bash ./security-scan.sh 2>&1); c64_rc=$? + c64_nskip=$(printf '%s\n' "$c64_out" | grep -c '^skip' || true) + [ "$c64_rc" -eq 0 ] \ + || c64_errs="$c64_errs\n$c64_sh exited $c64_rc with no scanner on PATH; a tool that is not installed is a skip, and failing there switches the whole gate off for everyone who has not installed five binaries" + [ "${c64_nskip:-0}" -ge 4 ] \ + || c64_errs="$c64_errs\n$c64_sh reported ${c64_nskip:-0} skip line(s) with no scanner on PATH; it declares five tools, and exit 0 over a report that named none of them is the fake green this check exists for" + + # Lane 2. The stub is what makes lane 1 mean anything: without it, a script whose every branch + # is `skip` passes lane 1 perfectly. + c64_stub="$c64_tmp/stub" + mkdir -p "$c64_stub" + printf '#!/bin/sh\necho "leak: generic-api-key at config.yml:3"\nexit 1\n' > "$c64_stub/gitleaks" + chmod 755 "$c64_stub/gitleaks" + c64_out2=$(cd "$c64_tmp" && env -i PATH="$c64_stub:/usr/bin:/bin" HOME="$c64_tmp" TMPDIR="$c64_tmp" \ + bash ./security-scan.sh 2>&1); c64_rc2=$? + rm -rf "$c64_tmp" + [ "$c64_rc2" -eq 1 ] \ + || c64_errs="$c64_errs\n$c64_sh exited $c64_rc2 against a gitleaks that reported a finding and exited 1; a scanner whose findings do not reach the exit code is decoration" + printf '%s\n' "$c64_out2" | grep -qE '^FAIL +gitleaks' \ + || c64_errs="$c64_errs\n$c64_sh did not print a FAIL line naming gitleaks against a scanner that found something: $(printf '%s' "$c64_out2" | tail -3 | tr '\n' ';')" + + # --- 64b, in the same body: the CI half, which cannot be executed here. + # + # Every action pinned to a 40-hex commit with the human-readable tag beside it. A tag is a + # mutable pointer somebody else controls, and `uses: foo@v7` in a workflow with repo write + # gives that somebody a push into this repo's CI. The `# v` suffix is required too: a bare SHA + # with no tag comment is unreviewable and never gets bumped. + while IFS= read -r c64_u; do + [ -n "$c64_u" ] || continue + printf '%s' "$c64_u" | grep -qE '@[0-9a-f]{40} # v' \ + || c64_errs="$c64_errs\n$c64_wf: $(printf '%s' "$c64_u" | sed 's/^[[:space:]]*//') is not pinned to a 40-hex commit with its tag beside it" + done </dev/null) +EOF + grep -qE '^[[:space:]]*uses:' "$c64_wf" 2>/dev/null \ + || c64_errs="$c64_errs\n$c64_wf declares no 'uses:' line at all, so the pinning rule above scanned nothing" + + # README's "Prod-ready gates" table is the published contract; $c64_sh is the implementation. + # The Tool column is extracted rather than typed here, so deleting a row is as visible as + # deleting the tool. Which of those tools the LOCAL gate is expected to run is named, not + # inferred -- npm audit and nuclei are CI and post-deploy only, and inferring the list from + # what the script mentions would let the check read its answer off its own subject. + c64_local="gitleaks semgrep osv-scanner zizmor eslint" + c64_tools=$(awk '/^## Prod-ready gates/{s=1; next} s && /^## /{s=0} s && /^\|/{print}' README.md 2>/dev/null \ + | sed -E 's/^\| *//; s/ *\|.*//') + for c64_t in $c64_local; do + grep -qxF "$c64_t" <<<"$c64_tools" \ + || c64_errs="$c64_errs\nREADME's Prod-ready gates table no longer names '$c64_t' in its Tool column, so the published contract and $c64_sh have drifted apart" + grep -qF "$c64_t" "$c64_sh" \ + || c64_errs="$c64_errs\n$c64_sh never mentions '$c64_t', which README's Prod-ready gates table says the local gate runs" + done + + # A payload file the overlay does not copy ships to nobody. All three landed in this repo + # before overlay.sh knew about any of them. + for c64_p in claude/security-scan.sh claude/security.yml.tmpl claude/dependabot.yml.tmpl; do + grep -qF "$c64_p" overlay.sh 2>/dev/null \ + || c64_errs="$c64_errs\noverlay.sh never names $c64_p, so it is payload this repository ships to itself" + done + + if [ -z "$c64_errs" ]; then + ok "$c64_lbl (${c64_nskip} skips at exit 0, stub finding caught at exit 1, $(grep -cE '^[[:space:]]*uses:' "$c64_wf") actions pinned$c64_note)" + else + bad "$c64_lbl" "$(printf '%b' "$c64_errs")" + fi +fi + # Accounting. Every declared check must have reported either a result or a skip. A check # that throws a shell error mid-body, or is wrapped in a conditional with no else, silently # reports nothing — and used to leave no trace in the output at all. Now it fails the run. diff --git a/CHANGELOG.md b/CHANGELOG.md index 07f8a94..2f91bfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ Versions follow [semver](https://semver.org). The version lives in two manifests `.claude-plugin/marketplace.json` and `claude/.claude-plugin/plugin.json`, and check 13 of `.claude/verify.sh` fails when they disagree. +## 1.67.0 — 2026-09-02 + +- **A security toolchain ships with the overlay.** `claude/security-scan.sh` runs gitleaks, + semgrep, osv-scanner, zizmor and eslint locally and is copied into every overlaid repo, where + the `verify.sh` template calls it; a scanner that is not installed skips rather than failing, so + the gate does not go red on a machine missing five binaries. `claude/security.yml.tmpl` and + `claude/dependabot.yml.tmpl` are seeded once into `.github/workflows/security.yml` and + `.github/dependabot.yml` and never overwritten — a second `vstack overlay .` reports whether the + file still matches the template or has drifted from it, and names the file to diff against. + `setup-machine.sh` installs the scanners by default. +- **Check 64 measures it in both directions.** The scan is run in a throwaway repo with + `PATH=/usr/bin:/bin` (every tool must skip, exit 0) and again against a stub gitleaks that + reports a finding (must exit 1 naming it), because a scan that can only ever skip passes the + first lane perfectly. 64b requires every `uses:` in the workflow template to be pinned to a + 40-hex commit with its tag beside it, and requires README's Prod-ready gates table and the + script to name the same tools. Falsifiability rows 64 and 64b delete the skip branch and unpin + one action. + ## 1.66.0 — 2026-09-02 Auto-enforcement tightened after measuring it. Across ten real sessions in the last ten days the diff --git a/README.md b/README.md index 2d43b7b..5c10f9f 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ Two directory pairs differ only by a leading dot, and the difference is the whol | path | what it is | |---|---| | `claude/` | the **shipped payload** — skills, subagents, commands, hooks, installed to `~/.claude/` | -| `.claude/verify.sh` | **this repository's own gate**, 64 checks; not shipped to anyone | +| `.claude/verify.sh` | **this repository's own gate**, 65 checks; not shipped to anyone | | `conductor/` | payload copied to `~/.conductor/` | | `.conductor/` | this repository's own workspace config | | `tests/` | the suites: the falsifiability harness, the install matrix, trigger and baseline tests | @@ -192,6 +192,15 @@ normally, but hooks, the CLI wrappers, the shell lane and MCP servers only land install. Take that lane if you want the Stop-hook gate, the destructive-command guard, or the CLI wrappers under `~/.config/agents/bin/`. +**`vstack overlay .`** (a third lane — drops config into a target *repo*, not `~/.claude`; the +only lane a cloud sandbox without your home directory can reach): + +| Source | Lands at | Condition | +|---|---|---| +| `claude/security-scan.sh` | `.claude/security-scan.sh` | always overwritten | +| `claude/security.yml.tmpl` | `.github/workflows/security.yml` | seeded if absent | +| `claude/dependabot.yml.tmpl` | `.github/dependabot.yml` | seeded if absent | + ## Day to day | Command | What it does | @@ -209,7 +218,7 @@ reaches for `unslop`, reviewing TypeScript reaches for `typescript-best-practice ## Checks that can fail -The gate is 64 checks (this number moves as checks are added; check 12 fails if this prose +The gate is 65 checks (this number moves as checks are added; check 12 fails if this prose and the tree disagree, so it stays honest by construction rather than by discipline). `tests/gate-falsifiability.sh` breaks the repository once per check, at least once and more where a check can fail in more than one way, requires the gate to go red @@ -218,13 +227,13 @@ naming that check, restores the tree byte for byte, and fails if anything was le can fail. ```bash -./.claude/verify.sh # 64 checks +./.claude/verify.sh # 65 checks VSTACK_FALSIFY_ROWS=27 ./tests/gate-falsifiability.sh # one row git clone . /tmp/vstack-check && cd /tmp/vstack-check && ./tests/gate-falsifiability.sh ``` The full sweep runs the whole gate once per mutation, so the cost is O(rows x checks). -At 108 falsifiability rows and a ~84s gate, that is over two hours serially. This paragraph claimed +At 110 falsifiability rows and a ~84s gate, that is over two hours serially. This paragraph claimed twenty minutes for four releases, which was the sharded figure wearing the serial one's label. `./tests/falsify-parallel.sh` runs the same sweep across seven isolated clones. That is the split CI uses, and CI finishes it in about 19 minutes because its seven shards are seven machines. @@ -300,6 +309,27 @@ Stated limits, none of which this fix closes: check, a human — which is outside a hook's reach. The gate is a guard against finishing on a red tree by accident, not against an adversary sharing your uid. +## Prod-ready gates + +Mandatory for any repo that is public or serves prod traffic. `vstack overlay .` installs the +local gate and CI; `/security` runs the post-deploy steps. + +| Tool | Local gate | CI | Post-deploy | Command | +|---|---|---|---|---| +| gitleaks | tracked files (+ `--staged` mode) | SHA-pinned action, `fetch-depth: 0` | history sweep | `gitleaks git --log-opts="--all" --no-banner --redact .` | +| semgrep | `p/typescript` `p/nextjs` `p/owasp-top-ten` | `semgrep ci` | | `semgrep scan --config p/owasp-top-ten .` | +| osv-scanner | `--lockfile` | `google/osv-scanner-action` | | `osv-scanner scan source --lockfile ` | +| zizmor | when `.github/workflows` exists | own job | | `zizmor --min-severity medium .github/workflows` | +| eslint | when config exists | `ci.yml` lint step | | `eslint .` | +| npm audit | | `--audit-level=high` when lockfile | | `npm audit --audit-level=high` | +| nuclei | | | `-u -severity medium,high,critical` | `nuclei -u -severity medium,high,critical -silent` | +| OWASP ZAP baseline | | | docker | `docker run --rm -t zaproxy/zap-stable zap-baseline.py -t ` | +| trivy | | | only if repo ships a container | `trivy fs --scanners vuln,secret,misconfig .` | + +Skipped on purpose: Snyk and SonarQube (commercial, redundant with the table above), husky and +lefthook (vstack gates via the Claude Stop hook, not git hooks — see +[What the hooks decide](#what-the-hooks-decide)). + ## The team Fourteen subagents, each with its own context window and its own tool allowlist, dispatched by the diff --git a/claude/.claude-plugin/plugin.json b/claude/.claude-plugin/plugin.json index 1d40e92..c395f17 100644 --- a/claude/.claude-plugin/plugin.json +++ b/claude/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vstack", - "version": "1.66.0", + "version": "1.67.0", "description": "Skills that fire on the situation instead of a slash command, plus the subagents, commands, and session hook that make them fire. Verification gates, parallel fan-out, code review, and writing discipline.", "author": { "name": "Vedant Kumar" diff --git a/claude/commands/security.md b/claude/commands/security.md index e968b14..4465982 100644 --- a/claude/commands/security.md +++ b/claude/commands/security.md @@ -5,36 +5,50 @@ description: Run security checks (secrets, dependencies, optional: external scan Run security checks on this project. **$ARGUMENTS** -vstack ships with built-in security checks in the verification gate. External security tools (OWASP ZAP, Trivy, Nuclei, nmap) are optional and can be installed separately if desired. +Mandatory for any repo that is public or serves prod traffic. -1. **Run the built-in verification gate.** +1. **Run the scanner.** ```bash - bash ./.claude/verify.sh + bash .claude/security-scan.sh ``` - This checks for: - - Hardcoded secrets (API keys, tokens, credentials): reads against real token shapes (sk-ant-, sk-proj-, github_pat-, etc.) - - Hardcoded home paths or infrastructure identifiers - - Committed files that look like credentials - - No other files in the repo are checked — only git-tracked files via `git grep`. - Report the results. - -2. **Run npm audit for dependency vulnerabilities** (JavaScript/Node projects only): + Runs gitleaks, semgrep, osv-scanner, and zizmor (eslint too, if a config exists and + `npm run lint` doesn't already cover it). Prints one line per tool: `ok NAME`, `FAIL NAME` + (with findings), or `skip NAME (reason)`. A `skip ... (not installed)` means the tool is + missing — run `./setup-machine.sh` (gitleaks, semgrep, osv-scanner, and zizmor install by + default there now). Report every line, not just the failures. + +2. **`npm audit` for dependency vulnerabilities** (only if `package-lock.json` exists): ```bash npm audit --audit-level=high ``` - This scans `package-lock.json` and `package.json` for known vulnerabilities in your dependency tree. Report findings. + Report findings. + +3. **History sweep** (catches a secret that's already committed, not just in the working tree): + ```bash + gitleaks git --log-opts="--all" --no-banner --redact . + ``` + A hit here needs `git filter-repo` (or BFG) and a force-push, not a new commit — the secret + is still in history either way. + +4. **Confirm CI is wired.** Check `.github/workflows/security.yml` and `.github/dependabot.yml` + exist. If either is missing, run `vstack overlay .` to seed them, then report what landed. -3. **Scan git history for secrets** (optional, catches mistakes that slipped through): +5. **Post-deploy scan** (optional — only run this if the user has supplied a deployed + preview/prod URL; skip otherwise): ```bash - git log --all --full-history -p | grep -iE 'api_key|secret|token|sk-' | head -20 + nuclei -u -severity medium,high,critical -silent + docker run --rm -t zaproxy/zap-stable zap-baseline.py -t ``` - This searches commit history for patterns that look like credentials. If found, use `git filter-branch` or `git-filter-repo` to remove them and force-push. + If the repo ships a container, also run `trivy fs --scanners vuln,secret,misconfig .`. -4. **For advanced scanning (optional, tools not shipped):** If the user has installed external tools and wants to use them: - - Trivy (filesystem/image/SBOM scanning): `trivy fs --scanners vuln,secret,misconfig .` - - npm's audit CI mode (CI/CD pipelines): `npm audit --audit-level=high --production` - - Container image scans (if building images): `trivy image myapp:latest` - - These tools are not provided by vstack; the user must install them separately and provide their own configuration. +6. **Report summary** as a table: -5. **Report summary.** Built-in gate status (pass/fail), npm audit findings (if any), git history scan (if run), and status of any optional external tools the user has installed. + | Tool | Result | Action | + |---|---|---| + | gitleaks | ok / FAIL / skip | ... | + | semgrep | ok / FAIL / skip | ... | + | osv-scanner | ok / FAIL / skip | ... | + | zizmor | ok / FAIL / skip | ... | + | npm audit | ok / FAIL / skip | ... | + | nuclei | ok / FAIL / n/a | ... | + | ZAP baseline | ok / FAIL / n/a | ... | diff --git a/claude/dependabot.yml.tmpl b/claude/dependabot.yml.tmpl new file mode 100644 index 0000000..a68639b --- /dev/null +++ b/claude/dependabot.yml.tmpl @@ -0,0 +1,43 @@ +# vstack overlay: seeded once into .github/dependabot.yml if absent. +# The target repo owns this file after seeding -- vstack will not overwrite local edits. +# Source: vstack claude/dependabot.yml.tmpl + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + # cooldown: malicious versions are usually caught within days; a short wait costs little. + cooldown: + default-days: 7 + + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + # cooldown: malicious versions are usually caught within days; a short wait costs little. + cooldown: + default-days: 7 + open-pull-requests-limit: 5 + groups: + minor-and-patch: + update-types: + - "minor" + - "patch" + + # Uncomment if the repo has a pyproject.toml / uv.lock (Python via uv). + # - package-ecosystem: "pip" + # directory: "/" + # schedule: + # interval: "weekly" + # day: "monday" + + # Uncomment if the repo has a Cargo.toml (Rust). + # - package-ecosystem: "cargo" + # directory: "/" + # schedule: + # interval: "weekly" + # day: "monday" diff --git a/claude/inventory.json b/claude/inventory.json index f575017..f5aa7c5 100644 --- a/claude/inventory.json +++ b/claude/inventory.json @@ -3,7 +3,7 @@ "contract_version": 1, "product": { "name": "vstack", - "version": "1.66.0", + "version": "1.67.0", "version_source": [ "claude/.claude-plugin/plugin.json", ".claude-plugin/marketplace.json" @@ -11,8 +11,8 @@ "repository": "https://github.com/itsvedantkumar/vstack" }, "derived_at": { - "head": "99e6e36b281194a92779323654a6a37b6b356d6a", - "payload_digest": "6616319c99b470affc585136991ab627d5cc6a003ece33f2bd3d83ad23c0f872", + "head": "4d0166f635f1e8647363b1eee769191512b9f547", + "payload_digest": "61967c2a5347ad45701ae581b99db1e624832a5144d7af58d2ec4756e3150ac8", "digest_recipe_source": "tests/inventory-contract.sh:payload_digest_compute()", "digest_recipe_note": "payload_digest hashes the working-tree BYTES of every payload file -- tracked and untracked-but-not-ignored -- together with its executable bit and path, then hashes that listing. Recompute it with `tests/inventory-contract.sh --print-digest` and nothing else: typing a recipe by hand into a shell is a second implementation. This field used to hold the recipe as an executable string that the validator eval'd, on the reasoning that the two could then never disagree. That made the artifact its own oracle -- editing the recipe and the digest together passed while measuring nothing -- and it also hashed index blob ids plus `git status --porcelain`, which names files and status letters but never their contents, so two different unstaged edits to the same path produced the same digest. Both were confirmed by running them.", "staleness_is_the_signal": "This file is a snapshot. When the payload digest no longer matches, the validator regenerates every derived field and diffs. A mismatch is the intended failure, not a maintenance chore: it means a component moved and at least one consumer has not been told.", @@ -519,7 +519,7 @@ }, "files": { "rule": "Ownership by receipt first, byte-identity second.", - "receipt": "$HOME/.config/agents/vstack-installed \u2014 install.sh appends every path it writes; survives a version change, where content comparison cannot.", + "receipt": "$HOME/.config/agents/vstack-installed — install.sh appends every path it writes; survives a version change, where content comparison cannot.", "byte_identity_fallback": "For a machine with no receipt yet, a target byte-identical to the repo file about to overwrite it is treated as vstack's.", "stated_trade": "A user file whose bytes exactly equal vstack's is indistinguishable from vstack's and is treated as vstack's, so uninstall deletes it. The alternative loses the ability to uninstall at all.", "uninstall_rules": [ @@ -557,7 +557,7 @@ "verification": { "gate": { "command": "./.claude/verify.sh", - "declared_checks": 64, + "declared_checks": 65, "check_ids": [ "0", "1", @@ -622,7 +622,8 @@ "61", "61b", "62", - "63" + "63", + "64" ], "accounting_rule": "ran + skipped must equal declared, or the gate fails. A check wrapped in a conditional with no else reports nothing and used to leave no trace.", "offline": true, @@ -643,7 +644,7 @@ "where": [ "ci:verify" ], - "rows": 108, + "rows": 110, "note": "Mutates the working tree. Run against a throwaway clone. The row total exceeds the declared-check total because some checks can fail in more than one way.", "cost_model": { "seconds_per_row": 65, diff --git a/claude/security-scan.sh b/claude/security-scan.sh new file mode 100755 index 0000000..bc60178 --- /dev/null +++ b/claude/security-scan.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# security-scan.sh — secret, static-analysis, and dependency scan for this repo. +# +# Run directly, or via .claude/verify.sh (which calls this if present). Prints one line per +# tool: `ok NAME`, `FAIL NAME` (+ up to 20 lines of output), or `skip NAME (reason)`. A tool +# that isn't installed is a skip, not a fail — this script never fails a repo for a missing +# scanner. Exit 1 iff at least one tool ran and failed; exit 0 otherwise. +set -u + +usage() { + cat <<'EOF' +usage: security-scan.sh [--staged] + +Runs gitleaks, semgrep, osv-scanner, zizmor, and (conditionally) eslint against the +current repo and prints one result line per tool. + + --staged scan staged changes only (gitleaks git --pre-commit --staged), instead of + the full working tree. + --help print this message. + +Env: + SECURITY_SCAN_TIMEOUT seconds allowed per tool before it's killed and reported as a + timeout failure (default 300). No-op if `timeout` isn't installed. +EOF +} + +staged=0 +for arg in "$@"; do + case "$arg" in + --staged) staged=1 ;; + --help|-h) usage; exit 0 ;; + *) echo "security-scan: unknown flag: $arg" >&2; usage >&2; exit 2 ;; + esac +done + +if root=$(git rev-parse --show-toplevel 2>/dev/null); then + cd "$root" || exit 1 +fi + +n_ok=0 +n_fail=0 +n_skip=0 +ok(){ printf 'ok %s\n' "$1"; n_ok=$((n_ok + 1)); } +bad(){ printf 'FAIL %s\n' "$1"; printf '%s\n' "$2" | sed 's/^/ /'; n_fail=$((n_fail + 1)); } +skip(){ printf 'skip %s (%s)\n' "$1" "$2"; n_skip=$((n_skip + 1)); } + +timeout_bin="" +command -v timeout >/dev/null 2>&1 && timeout_bin="timeout" +scan_timeout="${SECURITY_SCAN_TIMEOUT:-300}" + +# gl_tmp is the only cross-invocation tmp dir this script creates (for the gitleaks file-set +# copy below). One EXIT trap covers it regardless of where the script stops. +gl_tmp="" +cleanup() { [ -n "$gl_tmp" ] && rm -rf "$gl_tmp"; } +trap cleanup EXIT + +# run_scan: execute "$@" under `timeout` (when available), capturing combined output into +# $scan_out and its exit code into $scan_rc. A no-op wrapper when timeout isn't installed — +# the command still runs, it just can't be killed early. +scan_out="" +scan_rc=0 +run_scan() { + if [ -n "$timeout_bin" ]; then + scan_out=$("$timeout_bin" "$scan_timeout" "$@" 2>&1) + else + scan_out=$("$@" 2>&1) + fi + scan_rc=$? +} + +# report_scan NAME: turn $scan_rc/$scan_out from the last run_scan into an ok/FAIL line. +# Exit 124 from GNU/BSD `timeout` means the tool was killed for running too long — call that +# out by name rather than dumping whatever partial output it left behind. +report_scan() { + if [ "$scan_rc" -eq 0 ]; then + ok "$1" + elif [ "$scan_rc" -eq 124 ] && [ -n "$timeout_bin" ]; then + bad "$1" "timed out after ${scan_timeout}s" + else + bad "$1" "$(printf '%s' "$scan_out" | tail -20)" + fi +} + +# --- 1. gitleaks: secrets in the files git can see ---------------------------------------------- +if ! command -v gitleaks >/dev/null 2>&1; then + skip "gitleaks" "not installed" +elif ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + skip "gitleaks" "not a git repo" +else + # A repo-tracked .gitleaks.toml changes what counts as a leak (allowlists, custom rules). + # Report which one applied so a reviewer sees when an allowlist is in play. + if [ -f .gitleaks.toml ]; then gl_cfg_label=".gitleaks.toml"; else gl_cfg_label="default"; fi + + if [ "$staged" = 1 ]; then + run_scan gitleaks git --pre-commit --staged --no-banner --redact + if [ "$scan_rc" -eq 0 ]; then + ok "gitleaks (config: $gl_cfg_label)" + elif [ "$scan_rc" -eq 124 ] && [ -n "$timeout_bin" ]; then + bad "gitleaks" "timed out after ${scan_timeout}s" + else + bad "gitleaks" "$(printf '%s' "$scan_out" | tail -20)" + fi + else + # `gitleaks dir .` has no concept of .gitignore and will happily scan build output + # (.next, dist, node_modules, coverage, ...) as if it were source, reporting "leaks" that + # are really just bundled/minified vendor code. Scan only what git itself would see: the + # tracked files plus anything untracked that isn't ignored. Copy that file set into a + # throwaway directory (preserving paths) so gitleaks' target is exactly that set, then + # strip the tmp-dir prefix back out of anything we print. + gl_tmp=$(mktemp -d) + while IFS= read -r -d '' f; do + d=$(dirname "$f") + mkdir -p "$gl_tmp/$d" 2>/dev/null || continue + cp -p "$f" "$gl_tmp/$f" 2>/dev/null || true + done < <(git ls-files -co --exclude-standard -z) + + gl_cmd=(gitleaks dir --no-banner --redact -v) + if [ "$gl_cfg_label" = ".gitleaks.toml" ] && [ -f "$gl_tmp/.gitleaks.toml" ]; then + gl_cmd+=(--config "$gl_tmp/.gitleaks.toml") + fi + gl_cmd+=("$gl_tmp") + + run_scan "${gl_cmd[@]}" + # Physical path too: mktemp's dir can sit behind a symlink (macOS /var -> /private/var), + # and gitleaks reports whichever form it resolved, not necessarily the one we hold. + gl_tmp_phys=$(cd "$gl_tmp" 2>/dev/null && pwd -P || true) + scan_out=$(printf '%s' "$scan_out" | sed -e "s|$gl_tmp/||g" -e "s|${gl_tmp_phys:-$gl_tmp}/||g") + + if [ "$scan_rc" -eq 0 ]; then + ok "gitleaks (config: $gl_cfg_label)" + elif [ "$scan_rc" -eq 124 ] && [ -n "$timeout_bin" ]; then + bad "gitleaks" "timed out after ${scan_timeout}s" + else + bad "gitleaks" "$(printf '%s' "$scan_out" | tail -20)" + fi + + rm -rf "$gl_tmp" + gl_tmp="" + fi +fi + +# --- 2. semgrep: static analysis ---------------------------------------------------------------- +if ! command -v semgrep >/dev/null 2>&1; then + skip "semgrep" "not installed" +else + configs=(--config p/owasp-top-ten) + if [ -f package.json ]; then + configs=(--config p/typescript --config p/owasp-top-ten) + grep -q '"next"' package.json 2>/dev/null && configs+=(--config p/nextjs) + fi + if [ -f pyproject.toml ]; then + configs+=(--config p/python) + fi + run_scan semgrep scan "${configs[@]}" --error --quiet --metrics=off \ + --exclude node_modules --exclude .next --exclude dist . + report_scan "semgrep" +fi + +# --- 3. osv-scanner: known vulnerabilities in dependencies --------------------------------------- +lockfile="" +for f in package-lock.json pnpm-lock.yaml yarn.lock uv.lock poetry.lock Cargo.lock go.sum; do + [ -f "$f" ] && { lockfile="$f"; break; } +done +if ! command -v osv-scanner >/dev/null 2>&1; then + skip "osv-scanner" "not installed" +elif [ -z "$lockfile" ]; then + skip "osv-scanner" "no lockfile in repo" +else + run_scan osv-scanner scan source --lockfile "$lockfile" + report_scan "osv-scanner" +fi + +# --- 4. zizmor: GitHub Actions workflow audit ----------------------------------------------------- +if ! command -v zizmor >/dev/null 2>&1; then + skip "zizmor" "not installed" +elif [ ! -d .github/workflows ]; then + skip "zizmor" "no .github/workflows in repo" +else + run_scan zizmor --min-severity medium .github/workflows + report_scan "zizmor" +fi + +# --- 5. eslint: only when verify.sh's own `lint` script won't cover it ------------------------- +has_eslint_config=0 +for f in eslint.config.js eslint.config.mjs eslint.config.cjs eslint.config.ts \ + .eslintrc .eslintrc.js .eslintrc.cjs .eslintrc.json .eslintrc.yml .eslintrc.yaml; do + [ -f "$f" ] && has_eslint_config=1 +done +has_lint_script=0 +if [ -f package.json ]; then + node -e "process.exit(require('./package.json').scripts?.lint?0:1)" 2>/dev/null && has_lint_script=1 +fi +if [ "$has_eslint_config" = 0 ]; then + skip "eslint" "no eslint config in repo" +elif [ "$has_lint_script" = 1 ]; then + skip "eslint" "covered by npm run lint" +elif ! command -v npx >/dev/null 2>&1; then + skip "eslint" "not installed" +else + run_scan npx --no-install eslint . + report_scan "eslint" +fi + +echo "security-scan: $n_ok ok, $n_fail fail, $n_skip skip" +[ "$n_fail" -eq 0 ] diff --git a/claude/security.yml.tmpl b/claude/security.yml.tmpl new file mode 100644 index 0000000..1537097 --- /dev/null +++ b/claude/security.yml.tmpl @@ -0,0 +1,118 @@ +# vstack overlay: seeded once into .github/workflows/security.yml if absent. +# The target repo owns this file after seeding -- vstack will not overwrite local edits. +# Runs secret, SAST, dependency, and workflow-hygiene scans on PRs, pushes to main, and weekly. +# A .gitleaks.toml in the repo root (if present) is honored by the gitleaks job below. +# Source: vstack claude/security.yml.tmpl + +name: security + +on: + pull_request: + push: + branches: [main] + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: security-${{ github.ref }} + cancel-in-progress: true + +jobs: + gitleaks: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pull-requests: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Gitleaks + uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_ENABLE_UPLOAD_ARTIFACT: false + GITLEAKS_ENABLE_SUMMARY: true + + semgrep: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install semgrep + run: pip install semgrep==1.176.0 + - name: Run semgrep + run: | + semgrep scan \ + --config p/owasp-top-ten \ + --config p/typescript \ + --config p/nextjs \ + --config p/python \ + --error \ + --metrics=off \ + --exclude node_modules \ + --exclude .next \ + --exclude dist \ + . + + osv-scanner: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: OSV-Scanner + uses: google/osv-scanner-action/osv-scanner-action@6e4298ebc4db23e847df9b2e2de2939d6f066c67 # v2.5.1 + with: + scan-args: |- + -r + . + + zizmor: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: zizmor + uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 + with: + inputs: .github/workflows + advanced-security: false + annotations: true + fail-on-no-inputs: false + + npm-audit: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup Node + if: hashFiles('package-lock.json') != '' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + - name: Install dependencies + if: hashFiles('package-lock.json') != '' + run: npm ci --ignore-scripts + - name: npm audit + if: hashFiles('package-lock.json') != '' + run: npm audit --audit-level=high diff --git a/claude/verify.sh.tmpl b/claude/verify.sh.tmpl index e3f3511..d85ad8b 100644 --- a/claude/verify.sh.tmpl +++ b/claude/verify.sh.tmpl @@ -40,6 +40,16 @@ if [ -f Cargo.toml ] && command -v cargo >/dev/null; then out=$(cargo test --quiet 2>&1) && ok "cargo test" || bad "cargo test" "$(printf '%s' "$out" | tail -20)" fi +# --- 2. security scan -------------------------------------------------------------------------- +# Secrets, static analysis, and known-vulnerability checks. Lives in its own script so it can be +# run standalone (`bash .claude/security-scan.sh`) outside the Stop-hook gate too. +if [ -f .claude/security-scan.sh ]; then + ran=1 + out=$(bash .claude/security-scan.sh 2>&1) && ok "security-scan" || bad "security-scan" "$(printf '%s' "$out" | tail -20)" +else + skip "security-scan" ".claude/security-scan.sh absent — run: vstack overlay ." +fi + if [ "$ran" = 0 ]; then skip "project checks" "nothing detected — replace this file with checks for what this repo does" fi diff --git a/docs/what-this-actually-does.md b/docs/what-this-actually-does.md index 75069a4..6410d78 100644 --- a/docs/what-this-actually-does.md +++ b/docs/what-this-actually-does.md @@ -31,7 +31,7 @@ A number exists, sourced, dated. | On 2026-08-26 the gate declared 48 and was **red**: `48 declared, 47 ran, 1 skipped` and `VERIFICATION FAILED`, on `referenced install paths exist`, `inventory contract matches the tree` and `payload_digest`. All three are fixed. | `./.claude/verify.sh`, unpiped, on this branch | Run 2026-08-26 | | As of 2026-08-27 both gates are green: `48 declared, 47 ran, 1 skipped` and `VERIFIED`, and falsifiability harness returned `60 declared, 59 passed, 0 failed, 1 skipped` with `FALSIFIABLE` (superseded by rows added in 1.46.0; tree now declares 73 rows, 70 mutation + 3 fixed, not re-run for this document). Run unpiped in an isolated worktree at the candidate commit, exit codes read on their own line. | `./.claude/verify.sh` and `./tests/gate-falsifiability.sh` | Run 2026-08-27 | | The one remaining skip in both gates is check 24, and it is the designed state between a version bump and its tag, not a gap. The check's own source says so: it does not demand that HEAD be a release, only that the payload match the tag if one exists with the declared version. **Clearing it by tagging would fabricate the green** — a tag cut at HEAD makes the compared range empty by construction, so the check would report success having compared a commit to itself. The label was narrowed to say that instead. | `.claude/verify.sh` check 24, and its three branches exercised in a scratch clone | Verified 2026-08-27 | -| At v1.41.0, all 44 of the checks then declared had falsifiability rows. The tree now declares 64 checks and 108 falsifiability rows (105 mutation + 3 fixed), all watched for red under mutation. `tests/gate-falsifiability.sh` breaks exactly what each check watches, requires the gate to name it, and restores the file byte for byte. Check 16 fails the gate if a check has no such row. | `tests/gate-falsifiability.sh`, `tests/README.md`, README.md "Checks that can fail" | v1.41.0 baseline as of 2026-08-23; current counts as of this write. Suite not re-run for this document; rows 40 and 44 verified by the scoped-row method only, see note below | +| At v1.41.0, all 44 of the checks then declared had falsifiability rows. The tree now declares 65 checks and 110 falsifiability rows (107 mutation + 3 fixed), all watched for red under mutation. `tests/gate-falsifiability.sh` breaks exactly what each check watches, requires the gate to name it, and restores the file byte for byte. Check 16 fails the gate if a check has no such row. | `tests/gate-falsifiability.sh`, `tests/README.md`, README.md "Checks that can fail" | v1.41.0 baseline as of 2026-08-23; current counts as of this write. Suite not re-run for this document; rows 40 and 44 verified by the scoped-row method only, see note below | | `principle-type-system-discipline` almost never fired: 1/10 at n=10. Rewriting its description around the literal nouns a user types ("a struct, enum, or type can hold an invalid combination of fields") moved it to 9/10, matching the control. The identical rewrite method applied to `principle-build-the-lever` did not move it. That skill scored 2/10 before and after, exactly at the pre-registered falsification floor, and the rewrite was reverted rather than shipped. | CHANGELOG.md, "1.38.0" | 2026-08-23 | | `principle-prove-it-works` scored 0/10 on its own fixture prompt, because its trigger condition is about the assistant's own closing claim, not anything a skill matcher can see in the user's prompt. Replaced with a direct Stop-hook check (`prove-it-works`) rather than a rewritten description. | CHANGELOG.md, "1.37.0" | 2026-08-23 | | The container matrix's first run against published GitHub tags found two shipped defects. `bin/doctor` exited 1 on a clean Alpine install because its 45-day-cutoff `date` fallback chain covered BSD and GNU but BusyBox understands neither `date -v-45d` nor `date -d '45 days ago'`. And `vstack update`, run by anyone following the README's own documented pin quickstart (`VSTACK_REF=vX.Y.Z bash bootstrap.sh`), reported "already up to date" forever regardless of how far behind `main` the pinned checkout had drifted, because the shallow clone's refspec never fetches `origin/main` and the comparison failed silently with stderr discarded. | CHANGELOG.md, "1.33.0", and `tests/container-matrix.sh` | 2026-08-23 | diff --git a/overlay.sh b/overlay.sh index 85598b4..8df3095 100755 --- a/overlay.sh +++ b/overlay.sh @@ -115,6 +115,12 @@ fi echo "wrote .claude/hooks/policy.md" cp "$SRC/claude/statusline.sh" "$DEST/.claude/statusline.sh" && chmod 755 "$DEST/.claude/statusline.sh" echo "wrote .claude/statusline.sh" +# Overwritten every time, unlike the two CI templates below: this is vstack's own script, called +# by vstack's own verify.sh template, and a stale copy of it in a target repo is a scanner lane +# that silently runs last release's rules. Nothing in a target repo is expected to edit it -- +# that is what .gitleaks.toml and the workflow file are for. +cp "$SRC/claude/security-scan.sh" "$DEST/.claude/security-scan.sh" && chmod 755 "$DEST/.claude/security-scan.sh" +echo "wrote .claude/security-scan.sh" if command -v jq >/dev/null; then tmp=$(mktemp) jq '.statusLine = {type:"command", command:"\"$CLAUDE_PROJECT_DIR/.claude/statusline.sh\"", padding:0, refreshInterval:3}' \ @@ -140,12 +146,45 @@ echo "wrote .claude/{hooks,agents,commands,skills}" # repo's real gate matters far more than this placeholder. if [ -f "$DEST/.claude/verify.sh" ]; then echo "kept .claude/verify.sh (already exists)" + # A repo that already has a real gate keeps it -- and then has .claude/security-scan.sh sitting + # next to a gate that never calls it, which is indistinguishable from not shipping the scanner + # at all. Overwriting someone's gate to wire it in costs more than it buys, so say the one line + # they need and let them place it. Only the template (claude/verify.sh.tmpl) calls it already. + if ! grep -q security-scan.sh "$DEST/.claude/verify.sh"; then + echo "hint .claude/verify.sh does not call .claude/security-scan.sh — add: bash .claude/security-scan.sh || FAIL=1" + fi else cp "$SRC/claude/verify.sh.tmpl" "$DEST/.claude/verify.sh" chmod 755 "$DEST/.claude/verify.sh" echo "wrote .claude/verify.sh (template — write real checks, then 'vstack trust')" fi +# The CI half of the same lane. The local scan above catches a secret before it is committed; only +# a workflow catches one pushed from a machine that never ran the overlay, and only dependabot +# tracks the vulnerability that lands in a dependency next week. +# +# Seeded once and never overwritten: a repo's own security workflow and update policy outrank a +# template. "kept" alone could not tell a deliberate local edit from a copy that had drifted +# behind the template nobody re-read, so it says which -- and names the file to diff against, +# because a report that a file differs without saying from what is not actionable. +seed_tmpl(){ # + _st_src="$SRC/$1"; _st_dst="$DEST/$2" + [ -f "$_st_src" ] || { echo "error: missing $_st_src" >&2; return 1; } + mkdir -p "$(dirname "$_st_dst")" + if [ -f "$_st_dst" ]; then + if cmp -s "$_st_src" "$_st_dst"; then + echo "kept $2 (matches template)" + else + echo "kept $2 (differs from template — diff it against $_st_src)" + fi + else + cp "$_st_src" "$_st_dst" + echo "wrote $2 (template — tune it for this repo)" + fi +} +seed_tmpl claude/security.yml.tmpl .github/workflows/security.yml +seed_tmpl claude/dependabot.yml.tmpl .github/dependabot.yml + # Conductor: give the repo a verify button and, for cloud workspaces, a way to pull vstack # into the sandbox. Never overwrite an existing file — a repo's own setup script matters more # than this one, so print the lines to merge by hand instead. diff --git a/setup-machine.sh b/setup-machine.sh index 7172e97..2b30183 100755 --- a/setup-machine.sh +++ b/setup-machine.sh @@ -5,13 +5,18 @@ # # ./setup-machine.sh core + claude # ./setup-machine.sh --with-deploy also vercel and wrangler -# ./setup-machine.sh --with-security also trivy, gitleaks, nmap, nuclei +# ./setup-machine.sh --with-security also trivy, nmap, nuclei # ./setup-machine.sh --with-plugins also frontend-design, typescript-lsp (below) # ./setup-machine.sh --check report what is present, install nothing # ./setup-machine.sh --dry-run print what would be installed # # What each tier is for: -# core git, jq, ripgrep, fd, gh, node, bun, uv — the agent tooling and this installer +# core git, jq, ripgrep, fd, gh, node, bun, uv, +# gitleaks, semgrep, osv-scanner, zizmor — the agent tooling and this installer, +# plus the /security scanners: cheap, +# single-binary, and needed by every +# repo this script sets up, not just +# ones that opt into --with-security # bundled npm, npx, pnpm, yarn, python3 — verified, not installed: they come # with node or the Xcode tools # claude the Claude Code CLI itself @@ -22,7 +27,9 @@ # installing it by default made a # personal toolchain look like a # requirement of the product. -# security trivy, gitleaks, nmap, nuclei — the /security command +# security trivy, nmap, nuclei — heavier / less universal /security +# scanners: nmap and nuclei assume a +# live target, trivy assumes a container # plugins frontend-design, typescript-lsp — opt-in. Neither is vstack's code; both come # from anthropics/claude-plugins-official # and update on their own schedule. A @@ -216,12 +223,16 @@ note "" note "== core" apt_update_guard -ensure git git git -ensure jq jq jq -ensure rg ripgrep ripgrep rg -ensure fd fd fd-find fd -ensure gh gh gh -ensure node node nodejs node +ensure git git git +ensure jq jq jq +ensure rg ripgrep ripgrep rg +ensure fd fd fd-find fd +ensure gh gh gh +ensure node node nodejs node +ensure gitleaks gitleaks +ensure semgrep semgrep +ensure osv-scanner osv-scanner +ensure zizmor zizmor ensure_remote bun "https://bun.sh/install.sh" ensure_remote uv "https://astral.sh/uv/install.sh" @@ -357,7 +368,6 @@ if [ "$WITH_SECURITY" = 1 ]; then note "" note "== security" ensure trivy trivy - ensure gitleaks gitleaks ensure nmap nmap ensure nuclei nuclei note " OWASP ZAP is not installed here: it is a large Java app. Get it from zaproxy.org." diff --git a/tests/README.md b/tests/README.md index 02118a4..dfad4f4 100644 --- a/tests/README.md +++ b/tests/README.md @@ -5,7 +5,7 @@ Two suites, with opposite constraints. `gate-falsifiability.sh` runs offline and CI runs it on every push. It proves `.claude/verify.sh` can actually fail. It is also the slowest thing in this repository: every row breaks one file, runs the WHOLE gate to see which check goes red, and restores, so the cost is -O(rows x checks). At 108 falsifiability rows and a ~84s gate that is over two hours serially. +O(rows x checks). At 110 falsifiability rows and a ~84s gate that is over two hours serially. Run `falsify-parallel.sh` instead, the same sweep across isolated clones: about 19 minutes on CI's seven runners, 48 minutes locally at 103 rows on an M-series Mac (measured 2026-09-01, seven shards contending for one machine's cores). diff --git a/tests/gate-falsifiability.sh b/tests/gate-falsifiability.sh index 0a61c43..5707b4e 100755 --- a/tests/gate-falsifiability.sh +++ b/tests/gate-falsifiability.sh @@ -34,7 +34,7 @@ cd "$(dirname "${BASH_SOURCE[0]}")/.." || exit 1 . "$(pwd)/tests/lib-collision-guard.sh" # One id per `# --- N.` section in .claude/verify.sh. Check 16 parses this line. -CHECKS="0 1 1b 2 2b 3 3b 4 5 6 7 8 9 9b 10 10b 11 12 13 13b 13c 14 14b 14c 15 16 17 18 18b 18c 18d 19 20 20b 20c 21 22 23 24 25 26 27 28 29 29b 30 31 32 33 34 35 35b 35c 35d 35e 35f 35g 36 37 38 39 40 44 44b 44c 44d 44e 44f 44g 45 46 47 48 49 50 50b 50c 50d 51 51b 52 53 54 54b 55 55b 55c 56 56b 57 57b 57c 57d 58 58b 58c 27b 27c 59 60 12b 20d 61 61b 62 62b 63 63b" +CHECKS="0 1 1b 2 2b 3 3b 4 5 6 7 8 9 9b 10 10b 11 12 13 13b 13c 14 14b 14c 15 16 17 18 18b 18c 18d 19 20 20b 20c 21 22 23 24 25 26 27 28 29 29b 30 31 32 33 34 35 35b 35c 35d 35e 35f 35g 36 37 38 39 40 44 44b 44c 44d 44e 44f 44g 45 46 47 48 49 50 50b 50c 50d 51 51b 52 53 54 54b 55 55b 55c 56 56b 57 57b 57c 57d 58 58b 58c 27b 27c 59 60 12b 20d 61 61b 62 62b 63 63b 64 64b" CHECKS_ALL="$CHECKS" # Scoped runs: VSTACK_FALSIFY_ROWS="31 32 33" limits the mutation loop below to those ids, for # exercising a subset within a time budget instead of the full ~15 minute sweep. The CHECKS line @@ -304,6 +304,8 @@ files_for(){ case "$1" in 62b) printf 'bin/doctor' ;; 63) printf 'tests/transcript-census.py' ;; 63b) printf 'tests/transcript-census.sh' ;; + 64) printf 'claude/security-scan.sh' ;; + 64b) printf 'claude/security.yml.tmpl' ;; 9b) printf 'overlay.sh' ;; 10) printf 'claude/agents/debugger.md' ;; 10b) printf 'claude/agents/debugger.md' ;; @@ -410,6 +412,8 @@ label_for(){ case "$1" in 62b) printf "pre-tag carve-out is one finding, read by both harnesses, still hard in bin/doctor" ;; 63) printf "corpus census arithmetic is proven" ;; 63b) printf "corpus census arithmetic is proven" ;; + 64) printf "the security lane skips a missing scanner and fails on a finding" ;; + 64b) printf "the security lane skips a missing scanner and fails on a finding" ;; 9b) printf 'overlay merge path' ;; 10) printf 'agents + commands loadable' ;; 10b) printf 'agents + commands loadable' ;; @@ -752,6 +756,20 @@ exit 7 # the founding defect of this repository, and check 63's assertion floor is what catches it. sed -i.t 's/^expect "P1 /#expect "P1 /' tests/transcript-census.sh \ && rm -f tests/transcript-census.sh.t ;; + + 64) # Lane 1: take away the skip. A tool that is not installed becomes a failure, so the scan + # exits 1 on every machine that has not installed five binaries -- which is not a stricter + # gate, it is a gate everybody turns off. Check 64's no-scanner sandbox must go red on the + # exit code. Anchored on the reporter and the reason, not on the tool's own branch shape. + sed -i.t 's/^ skip "gitleaks" "not installed"$/ bad "gitleaks" "not installed"/' \ + claude/security-scan.sh && rm -f claude/security-scan.sh.t ;; + + 64b) # Unpin one action back to a floating tag. `@v7` is a pointer the action's owner can move, + # so a compromised upstream tag runs in this workflow on the next push -- the supply-chain + # hole the whole template exists to not have. One occurrence, not all five: a rule that only + # catches a wholesale unpinning is not the rule anybody needs. + perl -0pi -e 's{actions/checkout\@[0-9a-f]{40} \# v[0-9.]+}{actions/checkout\@v7}' \ + claude/security.yml.tmpl ;; 58c) # Remove the check count the measurement was taken at. Without it the recorded cost cannot # be scaled to this gate's size, so it would silently stay frozen at the size it was # measured on, which is how the constant it replaced went stale in the first place. A