Skip to content
Closed
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
7 changes: 6 additions & 1 deletion .claude/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,12 @@ this directory).
escalations, serialized lanes, gates and close-out. The main session
decides and raises gates; landers do the reviewing, merging,
verifying and releasing.
- `owasp-scan` — a parallel OWASP Top Ten scan of `src/`.
- `owasp-scan` — a parallel OWASP Top 10:2025 scan of a whole repo
(all languages, workflows, Dockerfiles, compose, scripts, config) plus
the permanent scanners, with ASVS 5.0 references and a report file per
run; takes an optional `asvs=V3,V7,V10` manual-review focus. Its
target discovery is `skills/owasp-scan/discover-targets.sh` (run with
`bash`, read-only; `--summary` and `--skipped` show what it skips).

Base lists each of these skills one by one in its sync manifest, so an
instance adds its own as `skills/<name>/` and lists that directory
Expand Down
441 changes: 368 additions & 73 deletions .claude/skills/owasp-scan/SKILL.md

Large diffs are not rendered by default.

129 changes: 129 additions & 0 deletions .claude/skills/owasp-scan/discover-targets.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# Print the owasp-scan target list for the repository in the current
# directory, one "<category><TAB><path>" line per file (read-only).
#
# Every git-tracked file is a target unless it is skipped for one of
# these reasons (tracked files are source, so there are no test, build,
# dist, target or generated exclusions):
# lockfile an explicit list of lockfile names (osv-scanner's job)
# vendored vendor/, third_party/, node_modules/ at any depth
# binary / empty not text
# too-large over 256 KiB (listed by --skipped, reported as not scanned)
# link-or-submodule a symlink or gitlink: its content is not scanned here
# missing tracked but absent from the worktree
#
# Category: the first rule that matches wins, in this order:
# 1 workflow .github/workflows/*.yml|*.yaml
# 2 workflow composite action (action.yml|action.yaml at any depth)
# 3 docker Dockerfile, Dockerfile.*, *.Dockerfile, Containerfile[.*]
# 4 compose compose[.*].y[a]ml, docker-compose*.y[a]ml
# 5 code by extension:
# shell *.sh *.bash *.zsh Makefile GNUmakefile *.mk
# python *.py tsjs *.ts *.tsx *.js *.jsx *.mjs *.cjs *.mts *.cts
# rust *.rs
# 6 shebang interpreter basename (also via /usr/bin/env [-S] [VAR=x] X):
# sh bash dash zsh ksh ash -> shell; python* pypy* -> python;
# node deno bun tsx ts-node -> tsjs
# 7 config *.yml *.yaml *.json *.jsonc *.toml *.ini *.cfg *.conf
# *.properties *.env .env .env.* Caddyfile
# 8 generic any other text file
#
# Usage: discover-targets.sh [--summary] [--skipped]
# (default) targets on stdout as "<category><TAB><path>", sorted
# --summary target counts per category, then "not-scanned <reason> <n>"
# --skipped "skip:<reason><TAB><path>" for every skipped tracked file
set -euo pipefail

mode=targets
for arg in "$@"; do
case "$arg" in
--summary|--skipped)
if [ "$mode" != targets ] && [ "$mode" != "${arg#--}" ]; then echo "--summary and --skipped cannot be combined" >&2; exit 2; fi
mode=${arg#--} ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
esac
done

MAX_BYTES=262144 # files over 256 KiB are listed, not scanned

skip_reason() { # $1 = path; prints a reason when the file is never a target
case "${1##*/}" in
package-lock.json|npm-shrinkwrap.json|pnpm-lock.yaml|yarn.lock|bun.lock|bun.lockb|deno.lock|flake.lock|composer.lock|Cargo.lock|uv.lock|poetry.lock|pdm.lock|Pipfile.lock|Gemfile.lock|go.sum|gradle.lockfile|packages.lock.json|Podfile.lock|mix.lock|pubspec.lock|.terraform.lock.hcl) echo lockfile; return ;;
esac
case "$1" in
node_modules/*|*/node_modules/*|vendor/*|*/vendor/*|third_party/*|*/third_party/*) echo vendored ;;
esac
}

SHEBANG_KIND=""
shebang_kind() { # $1 = path; sets SHEBANG_KIND from the interpreter basename
local first="" words word interp
SHEBANG_KIND=""
IFS= read -r first < "$1" 2>/dev/null || true
first=${first%$'\r'}
case "$first" in '#!'*) ;; *) return 0 ;; esac
read -r -a words <<< "${first#\#!}"
[ "${#words[@]}" -gt 0 ] || return 0
interp=${words[0]##*/}
if [ "$interp" = env ]; then
interp=""
for word in "${words[@]:1}"; do
case "$word" in -*|*=*) continue ;; esac
interp=${word##*/}
break
done
fi
case "$interp" in
sh|bash|dash|zsh|ksh|ash) SHEBANG_KIND=shell ;;
python*|pypy*) SHEBANG_KIND=python ;;
node|deno|bun|tsx|ts-node) SHEBANG_KIND=tsjs ;;
esac
}

categorise() { # $1 = path; prints the category
local base=${1##*/}
case "$1" in
.github/workflows/*.yml|.github/workflows/*.yaml) echo workflow; return ;;
esac
case "$base" in
action.yml|action.yaml) echo workflow; return ;;
Dockerfile|Dockerfile.*|*.Dockerfile|Containerfile|Containerfile.*) echo docker; return ;;
compose.yml|compose.yaml|compose.*.yml|compose.*.yaml|docker-compose*.yml|docker-compose*.yaml) echo compose; return ;;
*.sh|*.bash|*.zsh|Makefile|GNUmakefile|*.mk) echo shell; return ;;
*.py) echo python; return ;;
*.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs|*.mts|*.cts) echo tsjs; return ;;
*.rs) echo rust; return ;;
esac
shebang_kind "$1"
if [ -n "$SHEBANG_KIND" ]; then echo "$SHEBANG_KIND"; return; fi
case "$base" in
*.yml|*.yaml|*.json|*.jsonc|*.toml|*.ini|*.cfg|*.conf|*.properties|*.env|.env|.env.*|Caddyfile) echo config ;;
*) echo generic ;;
esac
}

emit() { # prints "<category-or-skip:reason>\t<path>" for every tracked file
local f reason
while IFS= read -r -d '' f; do
if [ -L "$f" ] || [ -d "$f" ]; then printf 'skip:link-or-submodule\t%s\n' "$f"; continue; fi
if [ ! -e "$f" ]; then printf 'skip:missing\t%s\n' "$f"; continue; fi
reason=$(skip_reason "$f")
if [ -n "$reason" ]; then printf 'skip:%s\t%s\n' "$reason" "$f"; continue; fi
if [ ! -s "$f" ]; then printf 'skip:empty\t%s\n' "$f"; continue; fi
if ! grep -Iq -- '' < "$f" 2>/dev/null; then printf 'skip:binary\t%s\n' "$f"; continue; fi
if [ "$(wc -c < "$f")" -gt "$MAX_BYTES" ]; then printf 'skip:too-large\t%s\n' "$f"; continue; fi
printf '%s\t%s\n' "$(categorise "$f")" "$f"
done < <(git ls-files -z)
}

case "$mode" in
targets) emit | grep -v '^skip:' | sort || true ;;
skipped) emit | grep '^skip:' | sort || true ;;
summary)
all=$(emit)
if [ -n "$all" ]; then
printf '%s\n' "$all" | grep -v '^skip:' | cut -f1 | sort | uniq -c | sort -rn || true
printf '%s\n' "$all" | grep '^skip:' | cut -f1 | sort | uniq -c | sed -E 's/^ *([0-9]+) skip:(.*)$/not-scanned \2 \1/' || true
fi
;;
esac
12 changes: 8 additions & 4 deletions .github/CONTENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
- `workflows/` — GitHub Actions; see its own `README.md`.
- `scripts/` — helper scripts used by the workflows; see its own
`README.md`.
- `renovate.json` — weekly update PRs for GitHub Actions, every
- `renovate.json` — weekly update PRs for GitHub Actions (pinned to a
full commit SHA with a `# vX.Y.Z` comment, kept current by
`helpers:pinGitHubActionDigests`), pre-commit hook revisions (the
`pre-commit` manager), Docker image digests, GitHub and OSV
vulnerability alerts, every
Dockerfile/compose image tag repo-wide (scanned recursively, unlike
Dependabot), and the `# renovate:` comment-annotated version pins in
`.devcontainer/Dockerfile` (and an instance's top-level runtime
Expand All @@ -30,6 +34,6 @@

## Don't

- Pin an Action to a floating tag like `@v7` or `@main` — pin the exact
patch version (see `docs/TEMPLATE.md`'s "Versions and config"
section).
- Pin an Action to a floating tag like `@v7` or `@main`, or to a tag at
all — pin the full commit SHA with a `# vX.Y.Z` comment (see
`docs/TEMPLATE.md`'s "Versions and config" and "Checks" sections).
14 changes: 13 additions & 1 deletion .github/renovate.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"extends": ["config:recommended", "helpers:pinGitHubActionDigests"],
"schedule": ["before 6am on monday"],
"pre-commit": {
"enabled": true
},
"vulnerabilityAlerts": {
"labels": ["security"]
},
"osvVulnerabilityAlerts": true,
"packageRules": [
{
"matchUpdateTypes": ["patch", "minor"],
Expand All @@ -20,6 +27,11 @@
"groupName": "python minor upgrade",
"automerge": false
},
{
"description": "Pin container images to their digest. Scoped to the docker managers on purpose: a global pinDigests also hits the pre-commit manager, whose tag revs can't be converted to digests (the update fails). GitHub Actions are pinned by helpers:pinGitHubActionDigests instead.",
"matchManagers": ["dockerfile", "docker-compose"],
"pinDigests": true
},
{
"description": "Renovate can't regenerate .devcontainer/devcontainer-lock.json, so features are bumped by hand together with it (see .devcontainer/README.md).",
"matchManagers": ["devcontainer"],
Expand Down
10 changes: 0 additions & 10 deletions .github/scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,6 @@
read-after-write lag for refs, so the error says to re-run once the tag is
visible. Tested against a fake `gh` in
`test_release_scripts.py`.
- `find_moderation_result.sh` / `test_moderation_scripts.py` — the check
step shared by `../workflows/moderate-bug-fix-apply.yml` and
`../workflows/moderate-feature-build-apply.yml`: asks the API for the
`moderation-result` artifact by name (so it is found however many
artifacts the run has) and writes `found=true|false`. It fails on an
expired artifact (a produced result that can no longer be applied; the
message says to re-run the worker run, since re-running the apply run
cannot bring the artifact back), an
API error, and an empty or unrecognised response. The tests run it
against a fake `gh`.
- `run_claude.sh` — invokes `claude -p` for one issue-moderation stage
inside the devcontainer; shared by all four Claude-invoking
`../workflows/moderate-*.yml` jobs. See `../workflows/README.md`'s
Expand Down
40 changes: 0 additions & 40 deletions .github/scripts/find_moderation_result.sh

This file was deleted.

106 changes: 76 additions & 30 deletions .github/scripts/moderate_apply_result.sh
Original file line number Diff line number Diff line change
@@ -1,19 +1,41 @@
#!/usr/bin/env bash
# Applies one worker job's packaged result (a git patch + outcome
# metadata -- data, never executed as code) for the feature-build/bug-fix
# moderation pipelines: pushes the branch, opens the PR, and relabels the
# issue. This is the privileged half of the split described in
# ../workflows/README.md's "Issue moderation" section -- shared so the
# actual repo-write logic lives in one place.
# moderation pipelines: pushes the branch, opens (or reuses) the PR, and
# relabels the issue. This is the privileged half of the split described
# in ../workflows/README.md's "Issue moderation" section -- shared so the
# actual repo-write logic lives in one place. Runs as the `apply` job of
# moderate-bug-fix.yml / moderate-feature-build.yml.
set -euo pipefail

result_dir="$1"
branch_prefix="$2" # "feature" or "bug"
branch_suffix="$3" # "plan" or "repro"
ready_label="$4" # e.g. "enhancement:plan-ready"
confirmed_label="$5" # e.g. "enhancement:confirmed"
done_label="$6" # e.g. "enhancement:built"
closes_verb="$7" # "Closes" or "Fixes"
done_label="$5" # e.g. "enhancement:built"
closes_verb="$6" # "Closes" or "Fixes"

# Idempotent by design, so a maintainer's retry (re-adding the confirm
# label, which the workflow's gate job consumed when the run started) is
# always safe after a partial first run:
# - the branch is pushed with --force-with-lease (a retry's `git am`
# makes new SHAs);
# - an open PR for the branch is reused, never duplicated;
# - the stage label swaps in one `gh issue edit`, after the PR exists
# (it also clears any needs-human an earlier failed attempt left).
# A failure leaves the stage at "ready" and flags needs-human.

# Failure path: needs-human first (the signal that matters), then a
# best-effort comment. The run link only appears when all of its parts exist.
fail() {
gh issue edit "$issue_number" --add-label "needs-human" || true
local run_url=""
if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then
run_url=" See $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID."
fi
gh issue comment "$issue_number" --body "$1$run_url Re-add the confirm label to retry once fixed." || true
exit 1
}

meta="$result_dir/meta.json"
issue_number="$(jq -r '.issue_number' "$meta")"
Expand All @@ -23,43 +45,67 @@ if ! [[ "$issue_number" =~ ^[0-9]+$ ]]; then
exit 1
fi

# Idempotency: a second /confirm on the same issue can queue a second
# worker run behind the first (the worker's own concurrency group
# serializes those), but this apply workflow isn't concurrency-grouped
# against it (workflow_run can't see the issue number to key on before
# downloading the artifact) -- so if the ready label is already gone, a
# prior apply run already processed this issue. No-op rather than
# double-push/double-PR.
current_labels="$(gh issue view "$issue_number" --json labels --jq '.labels[].name')"
# Re-adding the confirm label during a run queues a second run behind the
# first (the workflow's concurrency group serializes them). If the ready
# label is already gone, a prior run already finished this issue: no-op.
if ! current_labels="$(gh issue view "$issue_number" --json labels --jq '.labels[].name')"; then
fail "Reading the issue's labels failed."
fi
if ! grep -qxF "$ready_label" <<<"$current_labels"; then
echo "Issue #$issue_number no longer carries $ready_label -- already processed, skipping."
exit 0
fi

gh issue edit "$issue_number" --remove-label "$ready_label" --add-label "$confirmed_label"

outcome="$(jq -r '.outcome' "$meta")"

# Everything that can fail on a "built" result. Explicit `|| return 1`, not
# errexit: the caller runs this in an `if !` condition, which suspends it.
apply_built() {
local remote_sha pr_url
git config user.name "claude-moderator[bot]" || return 1
git config user.email "claude-moderator[bot]@users.noreply.github.com" || return 1
git fetch --depth=1 origin "$base_sha" || return 1
git checkout -B "$branch" FETCH_HEAD || return 1
git am "$result_dir/patch.diff" || return 1

# The branch is bot-owned: triage's Claude run creates `<prefix>/<n>-<suffix>`
# (the repro/plan branch this result builds on) and only this script writes
# to it afterwards. The lease pins the tip read here, so a push by anyone
# in between still fails. A tip other than base_sha must be a descendant of
# it (an earlier attempt's commits), otherwise it's foreign work: refuse.
remote_sha="$(git ls-remote origin "refs/heads/$branch" | cut -f1)" || return 1
if [ -n "$remote_sha" ] && [ "$remote_sha" != "$base_sha" ]; then
git fetch --depth=100 origin "$base_sha" "$remote_sha" || return 1
git merge-base --is-ancestor "$base_sha" "$remote_sha" || return 1
fi
git push --force-with-lease="$branch:$remote_sha" origin "$branch" || return 1

pr_url="$(gh pr list --head "$branch" --state open --json number,url --jq '.[0].url // ""')" || return 1
if [ -n "$pr_url" ]; then
echo "Reusing open PR $pr_url for $branch."
else
gh pr create --title "$subject" --body "$body

$closes_verb #$issue_number" --base main --head "$branch" || return 1
fi

# Only now that the PR exists does the stage move on.
gh issue edit "$issue_number" --remove-label "$ready_label" --remove-label "needs-human" --add-label "$done_label" || return 1
}

if [ "$outcome" = built ]; then
base_sha="$(jq -r '.base_sha' "$meta")"
branch="$branch_prefix/$issue_number-$branch_suffix"

git config user.name "claude-moderator[bot]"
git config user.email "claude-moderator[bot]@users.noreply.github.com"
git fetch --depth=1 origin "$base_sha"
git checkout -B "$branch" FETCH_HEAD
git am "$result_dir/patch.diff"
git push -u origin "$branch"

subject="$(jq -r '.subject' "$meta")"
body="$(jq -r '.body' "$meta")"
gh pr create --title "$subject" --body "$body

$closes_verb #$issue_number" --base main --head "$branch"

gh issue edit "$issue_number" --remove-label "$confirmed_label" --add-label "$done_label"
if ! apply_built; then
fail "Applying the automated result (patch, push, PR or relabel) failed."
fi
else
# Not built: the stage stays at "ready" on purpose, so a maintainer's
# retry (re-adding the confirm label) re-runs the build.
body="$(jq -r '.body' "$meta")"
gh issue edit "$issue_number" --add-label "needs-human" || true
gh issue comment "$issue_number" --body "$body"
gh issue edit "$issue_number" --add-label "needs-human"
fi
Loading
Loading