diff --git a/scripts/guardrails.sh b/scripts/guardrails.sh new file mode 100644 index 0000000..85670ff --- /dev/null +++ b/scripts/guardrails.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Static guardrail checks for game.js. Run from repo root. +# +# Each check prints a PASS/FAIL line and contributes to the final exit code. +# The rules enforced here come from AGENTS.md — keep the two files in sync. +# +# Some rules are marked PENDING: they describe invariants we've agreed on but +# haven't reached on main yet (because another open PR is landing the fix). +# PENDING rules print their current violation count without failing the job; +# convert them to hard failures once the blocking PR merges. + +set -u +fail=0 + +say() { printf '%s\n' "$*"; } +banner() { say ""; say "==== $* ===="; } + +# ---------- Rule 1: no *Boost mutation inside applyFn blocks ---------- +# Persistent multipliers (codeBoost, computeBoost, ...) must not be written +# from any function that runs per tick. The `applyFn` of a debuff is invoked +# on every updateRates() call, so `G.codeBoost *= 0.7` inside applyFn compounds +# and silently zeros code production. See AGENTS.md rule 1. +banner "Rule 1: no *Boost mutation inside applyFn" +rule1_hits=$(awk ' + /applyFn:/ { inFn=1; brace=0; next } + inFn { + n = gsub(/\{/, "{") + brace += n + if ($0 ~ /(codeBoost|computeBoost|knowledgeBoost|userBoost|impactBoost)[[:space:]]*([*\/+\-]=|=)/) { + print FILENAME ":" NR ": " $0 + } + n = gsub(/\}/, "}") + brace -= n + if (brace <= 0) inFn = 0 + } +' game.js) +if [ -z "$rule1_hits" ]; then + say " PASS" +else + say " FAIL — see AGENTS.md rule 1" + say "$rule1_hits" + fail=1 +fi + +# ---------- Rule 2: click power has a single source (getClickPower) ---------- +# The formula should live only inside getClickPower(). If it appears anywhere +# else, the sites will drift when someone changes the formula. +banner "Rule 2: click power formula has one source" +rule2_hits=$(grep -nE 'Math\.floor\(G\.buildings\.autocoder \* 0\.5\)' game.js || true) +rule2_count=0 +if [ -n "$rule2_hits" ]; then + rule2_count=$(printf '%s\n' "$rule2_hits" | grep -c .) +fi +if [ "$rule2_count" -le 1 ]; then + say " PASS ($rule2_count site)" +else + say " FAIL — $rule2_count sites; inline into getClickPower() only" + printf '%s\n' "$rule2_hits" + fail=1 +fi + +# ---------- Rule 3: loadGame uses a whitelist, not Object.assign ---------- +# Object.assign(G, data) lets a malicious or corrupted save file set any G +# field, and hides drift when saveGame's explicit list diverges from what +# the game actually reads. See AGENTS.md rule 3. +banner "Rule 3: loadGame uses a whitelist" +rule3_hits=$(grep -nE 'Object\.assign\(G,[[:space:]]*data\)' game.js || true) +if [ -z "$rule3_hits" ]; then + say " PASS" +else + say " FAIL — see AGENTS.md rule 3" + printf '%s\n' "$rule3_hits" + fail=1 +fi + +# ---------- Rule 7: no secrets in the tree ---------- +# Scans for common token prefixes. Expand the pattern list when new key +# formats appear in the fleet. See AGENTS.md rule 7. +banner "Rule 7: secret scan" +secret_hits=$(grep -rnE 'sk-ant-[a-zA-Z0-9_-]{6,}|sk-or-[a-zA-Z0-9_-]{6,}|ghp_[a-zA-Z0-9]{20,}|AKIA[0-9A-Z]{16}' \ + --include='*.js' --include='*.json' --include='*.md' --include='*.html' \ + --include='*.yml' --include='*.yaml' --include='*.py' --include='*.sh' \ + --exclude-dir=.git --exclude-dir=.gitea . || true) +# Strip our own literal-prefix patterns (this file, AGENTS.md, workflow) so the +# check doesn't match the very grep that implements it. +secret_hits=$(printf '%s\n' "$secret_hits" | grep -v -E '(AGENTS\.md|guardrails\.sh|guardrails\.yml)' || true) +if [ -z "$secret_hits" ]; then + say " PASS" +else + say " FAIL" + printf '%s\n' "$secret_hits" + fail=1 +fi + +banner "result" +if [ "$fail" = "0" ]; then + say "all guardrails passed" + exit 0 +else + say "one or more guardrails failed" + exit 1 +fi