diff --git a/docs/memory/PROJECT_MEMORY.md b/docs/memory/PROJECT_MEMORY.md index 506a74d7..01a4f903 100644 --- a/docs/memory/PROJECT_MEMORY.md +++ b/docs/memory/PROJECT_MEMORY.md @@ -142,3 +142,19 @@ - Example: issue #721 / PR #723 — added `subIssues[]` (full body + comments + labels via GraphQL) to the GitHub loader and deepened JIRA `subtasks[]` with description/comments/attachments. Both reviews (argos quality + athena security) converged 0/0/0 in iteration 1; `composer build` green (315 tests, 100% coverage). - Source: https://github.com/pekral/cursor-rules/pull/723 Added: 2026-06-29 - Role: shared + +### shared-skills-helper-dir-and-readme-skill-count — a non-skill helper dir under `skills/` needs the README count test to gate on SKILL.md + +- Trigger: adding a shared helper directory under `skills/` (e.g. `skills/_shared/` with sourced libs / standalone scripts) reused by more than one skill, instead of duplicating logic or a per-skill `_lib.sh`. +- Rule: Two gotchas. (1) The README skill-count test in `tests/Installer/SkillsContentTest.php` (`readme reports the current skill count …`) counted **every** directory under `skills/`, so a non-skill helper dir inflates the count and breaks the README assertions. Fix it to count only directories that ship a `SKILL.md` — that matches `skill-check`'s own definition of a skill (it reported 62 and ignored `_shared/`). (2) Cross-skill sourcing via `${SCRIPT_DIR}/../../_shared/lib.sh` resolves in consumer trees too: `src/Installer.php` copies the whole `skills/` tree verbatim (see [[skills-tree-verbatim-distribution]]), so all skills land under the same `skills/` parent and the relative path holds after install. A shared `skills/_shared/` lib is therefore compatible with verbatim distribution — the self-contained convention only applied to the JIRA transition-helper siblings. +- Example: issue #725 / PR #726 — `skills/_shared/attachments.sh` (sourced download lib) + `skills/_shared/scan-attachments.sh` (standalone gate) reused by all three `download-attachments.sh` wrappers; auth token kept out of argv by writing it only into a 0600 curl `--config` file (`header = "Authorization: …"`), TLS pinned with `--proto`/`--proto-redir '=https'`. Scripts have no exec tests (test-isolation rule) — the fixture proof lives in `scan-attachments.sh --self-test` and its outcomes are content-pinned in Pest. +- Source: https://github.com/pekral/cursor-rules/pull/726 Added: 2026-06-29 +- Role: talos + +### attachment-download-urls-need-an-ssrf-host-guard — fetching tracker-supplied URLs must block non-public hosts before the request + +- Trigger: writing or reviewing any skill/script that downloads a URL taken from issue-tracker content (attachment `contentUrl`, a URL scraped from comment/body Markdown, a webhook payload) — especially when the URL is user-controllable (e.g. a Bugsnag comment link, a GitHub issue body). +- Rule: TLS-on + size-cap + quarantine is not enough — an attacker-supplied URL is an SSRF vector. Block loopback / link-local (incl. the `169.254.169.254` cloud-metadata endpoint) / RFC-1918 / ULA hosts **before** issuing the request, and record the rejection in the manifest rather than fetching. Put the guard in the shared download path (`skills/_shared/attachments.sh` `att_host_block_reason`, called from `att_run`) so all trackers inherit it. Give self-hosted trackers an explicit opt-out (`ATT_ALLOW_PRIVATE_HOSTS=1`) — a blanket private-IP block would break a JIRA/GitHub-Enterprise install on an internal network. Host-pinning by regex (GitHub) or server-issued URLs (JIRA `contentUrl`) already constrain those trackers; the open surface is whichever wrapper scrapes free-text URLs (Bugsnag). DNS-rebinding (public name → private IP) needs `curl --resolve` pinning and is the residual gap to note, not silently ignore. +- Example: issue #725 / PR #726 — CR raised the Bugsnag SSRF as Moderate; fixed with `att_host_block_reason` + a pinning Pest test. Converged 0 Critical / 0 Moderate in 2 process-code-review iterations; `composer build` green (323 tests). Pairs with the token-out-of-argv-via-curl-`--config` pattern recorded in [[shared-skills-helper-dir-and-readme-skill-count]]. +- Source: https://github.com/pekral/cursor-rules/pull/726 Added: 2026-06-29 +- Role: shared diff --git a/skills/_shared/attachments.sh b/skills/_shared/attachments.sh new file mode 100755 index 00000000..b6da77f8 --- /dev/null +++ b/skills/_shared/attachments.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# attachments.sh — shared library for downloading issue-tracker attachments into a +# quarantine directory, writing a manifest, and handing every file to the mandatory +# security scan (scan-attachments.sh) before anything is promoted to safe/. +# +# Sourced, not executed. A tracker-specific download-attachments.sh wrapper: +# 1. sets PROG (used in messages) before sourcing +# 2. sources this file +# 3. resolves the attachment inventory as a JSON array of +# { "id", "name", "declaredMime", "size", "contentUrl" } +# 4. writes a curl auth config file (chmod 600) carrying ONLY the auth header +# (empty string when the source needs no auth) +# 5. calls att_run "" "" "" "" "" +# +# Security contract (rules/security/backend.md + frontend.md): +# - TLS validation is ALWAYS on. No -k / --insecure / --no-check-certificate, no +# verify=false; downloads are pinned to https via --proto '=https'. +# - The auth token lives only inside the 0600 curl --config file, never in argv, +# never in the manifest, never in a log line. +# - Downloaded bytes land in a dedicated quarantine dir with 0600 perms; nothing is +# opened or rendered before scan-attachments.sh runs. Only files the scan marks +# `pass` are copied into safe/ for the analysis to read. +# +# Exit codes honored by callers: 2 = missing tool, 3 = download/network failure. +set -euo pipefail + +: "${PROG:=${0##*/}}" + +ATT_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Per-file size cap (25 MiB) and per-issue count cap. Overridable via env for tests. +ATT_MAX_BYTES="${ATT_MAX_BYTES:-26214400}" +ATT_MAX_COUNT="${ATT_MAX_COUNT:-25}" + +att_require_tools() { + local bin + for bin in curl jq file; do + if ! command -v "$bin" >/dev/null 2>&1; then + echo "${PROG}: required tool not found: $bin" >&2 + exit 2 + fi + done +} + +# att_default_dest — default quarantine root under the session scratchpad (never the repo). +att_default_dest() { + local base="${CLAUDE_SCRATCHPAD_DIR:-${TMPDIR:-/tmp}}" + printf '%s/attachments' "${base%/}" +} + +# att_file_size — byte count, portably (no GNU/BSD stat divergence). +att_file_size() { + wc -c < "$1" | tr -d '[:space:]' +} + +# att_sha256 — lowercase hex digest only. +att_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +# att_safe_name — collision-free, traversal-free local filename. +# Strips any directory component and reduces to [A-Za-z0-9._-]; prefixes a zero-padded +# index so two attachments named "image.png" cannot overwrite each other. +att_safe_name() { + local idx="$1" name="$2" base + base="${name##*/}" + base="$(printf '%s' "$base" | LC_ALL=C tr -c 'A-Za-z0-9._-' '_')" + [[ -z "$base" || "$base" == '.' || "$base" == '..' ]] && base="attachment" + printf '%03d__%s' "$idx" "$base" +} + +# att_host_block_reason — echoes a block reason when the URL targets a +# non-public host, and nothing when the host is allowed. Keeps a user-supplied URL +# (e.g. a Bugsnag comment link) from driving an SSRF request to an internal service: +# loopback / link-local (incl. the cloud-metadata 169.254.169.254 endpoint), RFC-1918 +# / ULA private ranges, and obviously-internal hostnames are rejected before any +# request is made. A self-hosted tracker on a private network opts out with +# ATT_ALLOW_PRIVATE_HOSTS=1. DNS-rebinding (a public name resolving to a private IP) +# is out of scope — curl --resolve pinning would be the full fix; literal-IP and +# internal-hostname checks cover the realistic attacker-supplied-URL case here. +att_host_block_reason() { + local url="$1" host + host="$(printf '%s' "$url" | sed -nE 's#^https://([^/:]+).*#\1#p' | LC_ALL=C tr 'A-Z' 'a-z')" + if [[ -z "$host" ]]; then + printf 'non-https or unparseable URL' + return + fi + case "$host" in + localhost|*.local|*.internal|*.localdomain) printf 'internal hostname (%s)' "$host"; return;; + 127.*|0.0.0.0|169.254.*|::1|\[::1\]) printf 'loopback/link-local host (%s)' "$host"; return;; + esac + if [[ "${ATT_ALLOW_PRIVATE_HOSTS:-0}" != "1" ]]; then + case "$host" in + 10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*|fc*:*|fd*:*|\[fc*|\[fd*) + printf 'private-range host (%s); set ATT_ALLOW_PRIVATE_HOSTS=1 for a self-hosted tracker' "$host"; return;; + esac + fi +} + +# att_download — fetch one URL to with TLS on. +att_download() { + local url="$1" out="$2" cfg="$3" + local args=( + --location # follow redirects (e.g. GitHub asset -> signed storage URL) + --proto '=https' # HTTPS only; no downgrade. TLS validation stays ON (never -k). + --proto-redir '=https' # a redirect may not downgrade to http either + --fail-with-body # non-2xx -> non-zero exit, keep body for diagnostics + --silent --show-error # suppress the progress meter only; body goes to --output, never piped to a shell + --max-time 120 + --max-filesize "$ATT_MAX_BYTES" # abort oversized transfers before they fill the quarantine disk + --output "$out" + ) + # Auth header is read from the 0600 config file so the token never reaches argv/logs. + [[ -n "$cfg" ]] && args+=( --config "$cfg" ) + ( umask 077; curl "${args[@]}" "$url" ) +} + +# att_run +# Downloads every inventory item into /_quarantine/, writes +# attachments-manifest.json, then runs the mandatory security scan. +att_run() { + local inventory="$1" cfg="$2" dest="$3" tracker="$4" ref="$5" + + local quarantine="${dest%/}/_quarantine" + local safe="${dest%/}/safe" + local manifest="${dest%/}/attachments-manifest.json" + mkdir -p "$quarantine" "$safe" + chmod 700 "$quarantine" "$safe" + + local count + count="$(printf '%s' "$inventory" | jq 'length')" + + local entries='[]' + local i name declared url out localPath size sha status reason + for (( i = 0; i < count; i++ )); do + name="$(printf '%s' "$inventory" | jq -r ".[$i].name // \"attachment\"")" + declared="$(printf '%s' "$inventory" | jq -r ".[$i].declaredMime // \"\"")" + url="$(printf '%s' "$inventory" | jq -r ".[$i].contentUrl // \"\"")" + + status="downloaded"; reason=""; localPath=""; size=0; sha="" + + local hostReason + if (( i >= ATT_MAX_COUNT )); then + status="block"; reason="exceeds max attachment count (${ATT_MAX_COUNT})" + elif [[ -z "$url" ]]; then + status="block"; reason="no downloadable contentUrl in inventory" + elif hostReason="$(att_host_block_reason "$url")"; [[ -n "$hostReason" ]]; then + # SSRF guard: never issue a request to a non-public host from an inventory URL. + status="block"; reason="blocked host — ${hostReason}" + else + out="${quarantine}/$(att_safe_name "$i" "$name")" + if att_download "$url" "$out" "$cfg"; then + chmod 600 "$out" + localPath="$out" + size="$(att_file_size "$out")" + sha="$(att_sha256 "$out")" + else + echo "${PROG}: download failed for attachment '${name}'" >&2 + rm -f "$out" + status="block"; reason="download failed (network/auth) — TLS validation stayed on" + fi + fi + + entries="$(jq -c \ + --argjson e "$(printf '%s' "$inventory" | jq -c ".[$i]")" \ + --arg lp "$localPath" --arg st "$status" --arg rs "$reason" \ + --arg sz "$size" --arg sha "$sha" \ + '. + [{ + id: ($e.id // null), + name: ($e.name // null), + declaredMime: ($e.declaredMime // null), + size: ($sz | tonumber? // 0), + sha256: (if $sha == "" then null else $sha end), + localPath: (if $lp == "" then null else $lp end), + status: $st, + reason: (if $rs == "" then null else $rs end), + safePath: null + }]' <<< "$entries")" + done + + ( umask 077; jq -n \ + --arg tracker "$tracker" --arg ref "$ref" \ + --arg quarantine "$quarantine" --arg safe "$safe" \ + --argjson maxBytes "$ATT_MAX_BYTES" --argjson maxCount "$ATT_MAX_COUNT" \ + --argjson attachments "$entries" \ + '{ + tracker: $tracker, + issue: $ref, + quarantineDir: $quarantine, + safeDir: $safe, + limits: { maxBytes: $maxBytes, maxCount: $maxCount }, + attachments: $attachments + }' > "$manifest" ) + chmod 600 "$manifest" + + echo "${PROG}: downloaded ${count} attachment(s) to quarantine; running security scan…" >&2 + "${ATT_LIB_DIR}/scan-attachments.sh" "$dest" +} diff --git a/skills/_shared/scan-attachments.sh b/skills/_shared/scan-attachments.sh new file mode 100755 index 00000000..de19bf97 --- /dev/null +++ b/skills/_shared/scan-attachments.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +# scan-attachments.sh — mandatory, deterministic security gate over downloaded +# issue-tracker attachments. Runs AFTER download-attachments.sh and BEFORE any file +# is read or rendered by the analysis. +# +# Usage: +# scan-attachments.sh # scan the quarantine described by DEST_DIR/attachments-manifest.json +# scan-attachments.sh --self-test # generate malicious + benign fixtures and assert the verdicts +# +# For each quarantined file the scan derives a verdict written back into the manifest: +# pass — type is on the analysis allowlist and carries no active content; copied to /safe/ +# block — clearly unsafe (executable, archive, script, HTML, SVG with active content, +# polyglot, declared/actual MIME mismatch, over size limit); NEVER promoted, only reported +# review — type is outside the allowlist but not obviously malicious; the agent must route it to the +# security-review skill and MUST NOT open it until that verdict clears +# +# Allowlist (intended for analysis): png, jpg/jpeg, gif, webp, pdf, txt, log, csv, json. +# +# Security contract: nothing is opened before this scan; only `pass` files reach safe/. +# This script reads bytes with `file` and a bounded head sniff only — it never executes +# the scanned file and never disables TLS anywhere. +# +# Exit codes: +# 0 scan completed (verdicts written; blocked files are an expected outcome, not an error) +# 1 usage error +# 2 missing required tool (jq, file) +# 3 manifest missing or unreadable +# 4 --self-test detected a regression +set -euo pipefail + +PROG="${0##*/}" + +ATT_MAX_BYTES="${ATT_MAX_BYTES:-26214400}" + +for bin in jq file; do + if ! command -v "$bin" >/dev/null 2>&1; then + echo "${PROG}: required tool not found: $bin" >&2 + exit 2 + fi +done + +scan_file_size() { + wc -c < "$1" | tr -d '[:space:]' +} + +# att_mime_compatible — true when the declared MIME is consistent +# with the magic-byte-detected MIME (exact, jpeg alias, or both text/*). +att_mime_compatible() { + local declared="$1" detected="$2" + [[ "$declared" == "$detected" ]] && return 0 + case "${declared}|${detected}" in + image/jpg\|image/jpeg|image/jpeg\|image/jpg|image/pjpeg\|image/jpeg) return 0;; + esac + [[ "$declared" == text/* && "$detected" == text/* ]] && return 0 + return 1 +} + +# att_classify — echoes "\t". +# Deterministic checks only; no execution of the scanned file. +att_classify() { + local path="$1" declared="$2" + local size detected head verdict + + size="$(scan_file_size "$path")" + if (( size == 0 )); then + printf 'block\tempty file'; return + fi + if (( size > ATT_MAX_BYTES )); then + printf 'block\texceeds max size (%s > %s bytes)' "$size" "$ATT_MAX_BYTES"; return + fi + + detected="$(file --mime-type -b "$path" 2>/dev/null || echo application/octet-stream)" + head="$(head -c 4096 "$path" 2>/dev/null | LC_ALL=C tr -d '\000')" + + # --- clearly-unsafe categories (checked before the allowlist) --- + case "$detected" in + application/x-executable|application/x-elf|application/x-mach-binary|application/x-dosexec|application/x-sharedlib|application/x-pie-executable|application/x-object) + printf 'block\texecutable binary (%s)' "$detected"; return;; + application/zip|application/gzip|application/x-tar|application/x-bzip2|application/x-xz|application/x-7z-compressed|application/x-rar|application/vnd.rar) + printf 'block\tarchive not permitted without explicit opt-in (%s)' "$detected"; return;; + text/x-shellscript|application/x-sh|application/x-csh|text/x-perl|text/x-python|text/x-ruby|application/javascript|text/javascript|application/x-httpd-php|text/x-php) + printf 'block\tscript content (%s)' "$detected"; return;; + application/vnd.ms-office|application/x-msdownload) + printf 'block\tOffice/macro-capable binary (%s)' "$detected"; return;; + esac + + if [[ "$head" == '#!'* ]]; then + printf 'block\tscript with shebang'; return + fi + + shopt -s nocasematch + if [[ "$detected" == text/html ]] || [[ "$head" == *'/on*/)'; return + fi + shopt -u nocasematch + printf 'block\tSVG not in analysis allowlist'; return + fi + + case "$detected" in + image/png|image/jpeg|image/gif|image/webp) + if [[ "$head" == *'&2 + exit 3 + fi + mkdir -p "$safe"; chmod 700 "$safe" + + local n i status lp declared res verdict reason safePath tmp passes blocks reviews + n="$(jq '.attachments | length' "$manifest")" + passes=0; blocks=0; reviews=0 + for (( i = 0; i < n; i++ )); do + status="$(jq -r ".attachments[$i].status" "$manifest")" + if [[ "$status" != "downloaded" ]]; then + [[ "$status" == "block" ]] && blocks=$((blocks + 1)) + continue + fi + lp="$(jq -r ".attachments[$i].localPath // \"\"" "$manifest")" + declared="$(jq -r ".attachments[$i].declaredMime // \"\"" "$manifest")" + if [[ -z "$lp" || ! -r "$lp" ]]; then + verdict="block"; reason="quarantine file missing" + else + res="$(att_classify "$lp" "$declared")" + verdict="${res%%$'\t'*}"; reason="${res#*$'\t'}" + fi + + safePath="null" + if [[ "$verdict" == "pass" ]]; then + cp "$lp" "${safe}/$(basename "$lp")" + chmod 600 "${safe}/$(basename "$lp")" + safePath="\"${safe}/$(basename "$lp")\"" + passes=$((passes + 1)) + elif [[ "$verdict" == "review" ]]; then + reviews=$((reviews + 1)) + else + blocks=$((blocks + 1)) + fi + + tmp="$(mktemp)" + # Clean up the temp file even if jq aborts mid-loop under set -e, so it is never orphaned. + if jq --argjson i "$i" --arg s "$verdict" --arg r "$reason" --argjson sp "$safePath" \ + '.attachments[$i].status = $s | .attachments[$i].reason = $r | .attachments[$i].safePath = $sp' \ + "$manifest" > "$tmp"; then + mv "$tmp" "$manifest" + else + rm -f "$tmp" + echo "${PROG}: failed to update manifest entry $i" >&2 + exit 3 + fi + chmod 600 "$manifest" + done + + echo "${PROG}: scan complete — ${passes} pass, ${blocks} block, ${reviews} review. Read only files under ${safe}/." >&2 +} + +self_test() { + local dir + dir="$(mktemp -d)" + # shellcheck disable=SC2064 + trap "rm -rf '$dir'" EXIT + local q="${dir}/_quarantine" + mkdir -p "$q" + + # benign 1x1 PNG (valid signature + IHDR) -> pass + printf '\211PNG\r\n\032\n\000\000\000\015IHDR\000\000\000\001\000\000\000\001\010\006\000\000\000\037\025\304\211\000\000\000\012IDATx\234c\000\001\000\000\005\000\001\r\n-\262\000\000\000\000IEND\256B\140\202' > "${q}/000__ok.png" + # malicious SVG with active content -> block + printf '' > "${q}/001__evil.svg" + # HTML masquerading as .txt -> block + printf '' > "${q}/002__page.html" + # polyglot: PNG magic with embedded script -> block + printf '\211PNG\r\n\032\n' > "${q}/003__poly.png" + + cat > "${dir}/attachments-manifest.json" <&2 + fail=1 + fi + } + assert_status 1 pass + assert_status 2 block + assert_status 3 block + assert_status 4 block + + # the benign PNG must have been promoted to safe/, the malicious ones must not be + if [[ ! -f "${dir}/safe/000__ok.png" ]]; then + echo "FAIL: benign PNG was not promoted to safe/" >&2; fail=1 + fi + if [[ -f "${dir}/safe/001__evil.svg" || -f "${dir}/safe/002__page.html" || -f "${dir}/safe/003__poly.png" ]]; then + echo "FAIL: a blocked file leaked into safe/" >&2; fail=1 + fi + + if (( fail == 0 )); then + echo "${PROG}: --self-test PASS (benign PNG promoted, malicious SVG/HTML/polyglot blocked)" + exit 0 + fi + echo "${PROG}: --self-test FAILED" >&2 + exit 4 +} + +if [[ "${1:-}" == "--self-test" ]]; then + self_test +fi + +if [[ $# -ne 1 || -z "${1:-}" || "${1:-}" == -* ]]; then + echo "Usage: scan-attachments.sh " >&2 + echo " scan-attachments.sh --self-test" >&2 + exit 1 +fi + +scan_manifest "$1" diff --git a/skills/analyze-problem/SKILL.md b/skills/analyze-problem/SKILL.md index 1158ffab..fe5ca4e3 100644 --- a/skills/analyze-problem/SKILL.md +++ b/skills/analyze-problem/SKILL.md @@ -43,7 +43,14 @@ Whenever the problem references an issue-tracker source (a GitHub issue / PR, a - **JIRA:** `skills/code-review-jira/scripts/gather-issue-context.sh ` - **Bugsnag:** `skills/code-review-bugsnag/scripts/gather-issue-context.sh ` If the gatherer is unavailable (missing tool / token, exit code 2/3), fall back to the tracker-specific MCP server; prefer issue-tracker-specific tools over generic browsing. -- Read the inventoried attachments and external URLs with your own tools and follow useful links recursively to a sensible depth — the gatherers inventory these but cannot fetch their content. +- **Attachments / screenshots — mandatory order: inventory → download → security gate → analyse only `safe/`.** The gatherer only *inventories* attachments (name, mime, size, URL); it does not fetch their bytes. Before reading or rendering any attachment you **must** run the tracker's download + scan pipeline and then read **only** the files the scan promoted to `safe/`: + - **GitHub:** `skills/code-review-github/scripts/download-attachments.sh ` (auth via `gh auth token`) + - **JIRA:** `skills/code-review-jira/scripts/download-attachments.sh ` (HTTP Basic `email:token`; the token is read from `--token-file`, then `JIRA_API_TOKEN`, then `~/.config/acli/jira_api_token`, with the account email in `JIRA_API_EMAIL` or the `email:token` form of the token file — without a token the script exits non-zero with a setup hint, it never silently skips) + - **Bugsnag:** `skills/code-review-bugsnag/scripts/download-attachments.sh ` (`BUGSNAG_TOKEN` authenticates the API read only; comment-linked URLs are fetched unauthenticated so the token never reaches a third-party host) + + Each download script writes downloaded bytes into a 0600 quarantine directory with **TLS validation always on**, emits `attachments-manifest.json`, and then runs the shared security gate `skills/_shared/scan-attachments.sh`. The gate assigns every file a verdict: `pass` (allowlisted type with no active content — copied to `safe/`), `block` (executable, archive, script, HTML, SVG with active content, polyglot, declared/actual MIME mismatch, or over the size/count limit — **never opened, only reported**), or `review` (type outside the allowlist — route it to the `security-review` (or `security-threat-analysis`) skill and **do not open it until that verdict clears**; a **Critical** verdict means the file stays blocked and is only reported, never analysed). +- **Read only files under `safe/`.** Never open, render, or `Read` a quarantined file that the gate did not promote. Record every blocked / review-pending attachment — with its manifest `reason` — in the **Assumptions and Missing Information** and **Sources** sections rather than guessing at its content. +- Read the inventoried external URLs with your own tools and follow useful links recursively to a sensible depth — the gatherers inventory these but cannot fetch their content. - When no issue-tracker source is available (the problem is described only inline), state that explicitly in the analysis and proceed from the inline context — there is nothing to load. - Record every source you actually consulted; it is reported in the **Sources** section of the output (see *Output Structure*). diff --git a/skills/code-review-bugsnag/scripts/download-attachments.sh b/skills/code-review-bugsnag/scripts/download-attachments.sh new file mode 100755 index 00000000..ec717ab4 --- /dev/null +++ b/skills/code-review-bugsnag/scripts/download-attachments.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# download-attachments.sh — download attachments referenced by a Bugsnag error into a +# quarantine directory and hand them to the mandatory security scan. +# +# Bugsnag's Data Access API exposes no file-attachment resource — screenshots and logs +# are linked as URLs inside the error comments. This script loads the error via the +# deterministic load-issue.sh (authenticated with BUGSNAG_TOKEN) and extracts those +# comment-linked file URLs. +# +# Usage: +# download-attachments.sh [--dest DIR] +# +# Auth: BUGSNAG_TOKEN authenticates the API read only (load-issue.sh). The linked +# attachment URLs are third-party hosts, so the Bugsnag token is NEVER forwarded to +# them — downloads run unauthenticated with TLS validation on. This prevents leaking +# the org token to an external host. +# +# Exit codes: +# 1 usage error +# 2 missing tool / missing BUGSNAG_TOKEN +# 3 inventory load or download failure +set -euo pipefail + +PROG="download-attachments.sh" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SHARED_DIR="${SCRIPT_DIR}/../../_shared" + +usage() { + cat >&2 <<'EOF' +Usage: download-attachments.sh [--dest DIR] + + --dest quarantine root (default: $CLAUDE_SCRATCHPAD_DIR/attachments) + +Auth: export BUGSNAG_TOKEN (Data Access API token) to read the error. +EOF +} + +REF="" +DEST="" +while [[ $# -gt 0 ]]; do + case "$1" in + --dest) DEST="${2:-}"; shift 2;; + -h|--help) usage; exit 1;; + -*) echo "${PROG}: unknown option: $1" >&2; usage; exit 1;; + *) if [[ -z "$REF" ]]; then REF="$1"; shift; else echo "${PROG}: unexpected argument: $1" >&2; exit 1; fi;; + esac +done + +if [[ -z "$REF" ]]; then usage; exit 1; fi + +# shellcheck source=../../_shared/attachments.sh +. "${SHARED_DIR}/attachments.sh" +att_require_tools + +if [[ -z "${BUGSNAG_TOKEN:-${BUGSNAG_AUTH_TOKEN:-}}" ]]; then + echo "${PROG}: BUGSNAG_TOKEN is not set (export a Data Access API token)." >&2 + exit 2 +fi + +[[ -z "$DEST" ]] && DEST="$(att_default_dest)/bugsnag" + +# stderr suppressed: the loader's own diagnostics are noise; its result is validated on the next line. +ERROR_JSON="$("${SCRIPT_DIR}/load-issue.sh" "$REF" 2>/dev/null || true)" +if [[ -z "$ERROR_JSON" ]] || ! printf '%s' "$ERROR_JSON" | jq -e . >/dev/null 2>&1; then + echo "${PROG}: failed to load Bugsnag error inventory for: $REF" >&2 + exit 3 +fi + +# Pull file-looking URLs (with an analysable extension) out of the comment bodies. +ALL_TEXT="$(printf '%s' "$ERROR_JSON" | jq -r '[ (.comments // [])[].body ] | map(select(. != null)) | join("\n")')" +URLS="$(printf '%s' "$ALL_TEXT" \ + | grep -oiE 'https://[^][:space:]")<>]+\.(png|jpe?g|gif|webp|pdf|txt|log|csv|json)' \ + | sort -u || true)" + +INVENTORY="$(printf '%s\n' "$URLS" | jq -R -s ' + split("\n") | map(select(length > 0)) + | to_entries | map({ + id: (.key | tostring), + name: (.value | sub("[?#].*$"; "") | split("/") | last), + declaredMime: null, + size: null, + contentUrl: .value + }) +')" + +COUNT="$(printf '%s' "$INVENTORY" | jq 'length')" +if [[ "$COUNT" -eq 0 ]]; then + echo "${PROG}: no comment-linked attachments found on $REF (nothing to download)." >&2 +fi + +# No auth config: the Bugsnag token must not be forwarded to third-party hosts. +att_run "$INVENTORY" "" "$DEST" "bugsnag" "$REF" diff --git a/skills/code-review-github/scripts/download-attachments.sh b/skills/code-review-github/scripts/download-attachments.sh new file mode 100755 index 00000000..52780338 --- /dev/null +++ b/skills/code-review-github/scripts/download-attachments.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# download-attachments.sh — download every attachment of a GitHub issue / PR (and its +# sub-issues / comments) into a quarantine directory and hand them to the mandatory +# security scan. +# +# GitHub has no structured attachment field — uploaded files live as inline URLs in +# the issue / comment Markdown. This script extracts those URLs from the body and all +# comments (via the deterministic load-issue.sh) and downloads each one. +# +# Usage: +# download-attachments.sh [--dest DIR] +# +# Auth: a Bearer token from `gh auth token`, written only into a 0600 curl --config +# file (never in argv / logs). The token is sent to github.com only — curl follows the +# redirect to signed storage with `-L` (not --location-trusted), so the credential is +# not forwarded to the storage host. TLS validation stays on throughout. +# +# Exit codes: +# 1 usage error +# 2 missing tool / no gh auth token +# 3 inventory load or download failure +set -euo pipefail + +PROG="download-attachments.sh" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SHARED_DIR="${SCRIPT_DIR}/../../_shared" + +usage() { + cat >&2 <<'EOF' +Usage: download-attachments.sh [--dest DIR] + + NUMBER|URL GitHub issue / PR number or URL + --dest quarantine root (default: $CLAUDE_SCRATCHPAD_DIR/attachments) + +Auth: uses `gh auth token` (run `gh auth login` first). +EOF +} + +REF="" +DEST="" +while [[ $# -gt 0 ]]; do + case "$1" in + --dest) DEST="${2:-}"; shift 2;; + -h|--help) usage; exit 1;; + -*) echo "${PROG}: unknown option: $1" >&2; usage; exit 1;; + *) if [[ -z "$REF" ]]; then REF="$1"; shift; else echo "${PROG}: unexpected argument: $1" >&2; exit 1; fi;; + esac +done + +if [[ -z "$REF" ]]; then usage; exit 1; fi + +# shellcheck source=../../_shared/attachments.sh +. "${SHARED_DIR}/attachments.sh" +att_require_tools +if ! command -v gh >/dev/null 2>&1; then + echo "${PROG}: required tool not found: gh" >&2 + exit 2 +fi + +[[ -z "$DEST" ]] && DEST="$(att_default_dest)/github" + +# stderr suppressed: gh's own diagnostics are noise; the empty-token case is handled on the next line. +TOKEN="$(gh auth token 2>/dev/null || true)" +if [[ -z "$TOKEN" ]]; then + echo "${PROG}: no GitHub token from 'gh auth token'. Run 'gh auth login' first." >&2 + exit 2 +fi + +# --- load body + comments via the deterministic loader and extract attachment URLs --- +# stderr suppressed: the loader's own diagnostics are noise; its result is validated on the next line. +ISSUE_JSON="$("${SCRIPT_DIR}/load-issue.sh" "$REF" 2>/dev/null || true)" +if [[ -z "$ISSUE_JSON" ]] || ! printf '%s' "$ISSUE_JSON" | jq -e . >/dev/null 2>&1; then + echo "${PROG}: failed to load GitHub issue inventory for: $REF" >&2 + exit 3 +fi + +# Collect every Markdown text surface (body + comments + sub-issue bodies/comments), +# then pull GitHub-hosted upload URLs out of them. Only github.com / githubusercontent +# hosts are followed, per the outbound-allowlist rule in rules/security/backend.md. +ALL_TEXT="$(printf '%s' "$ISSUE_JSON" | jq -r ' + ( [ .body ] + + [ (.comments // [])[].body ] + + [ (.subIssues // [])[].body ] + + [ (.subIssues // [])[] | (.comments // [])[].body ] + ) | map(select(. != null)) | join("\n") +')" + +URLS="$(printf '%s' "$ALL_TEXT" \ + | grep -oE 'https://(github\.com/user-attachments/(assets|files)/[^][:space:]")<>]+|github\.com/[^/]+/[^/]+/(assets|files)/[^][:space:]")<>]+|[a-z-]*\.?githubusercontent\.com/[^][:space:]")<>]+)' \ + | sort -u || true)" + +INVENTORY="$(printf '%s\n' "$URLS" | jq -R -s ' + split("\n") | map(select(length > 0)) + | to_entries | map({ + id: (.key | tostring), + name: (.value | sub("[?#].*$"; "") | split("/") | last), + declaredMime: null, + size: null, + contentUrl: .value + }) +')" + +COUNT="$(printf '%s' "$INVENTORY" | jq 'length')" +if [[ "$COUNT" -eq 0 ]]; then + echo "${PROG}: no inline attachments found on $REF (nothing to download)." >&2 +fi + +# --- write the 0600 curl auth config (token stays out of argv) --- +CFG="$(mktemp)" +chmod 600 "$CFG" +trap 'rm -f "$CFG"' EXIT +printf 'header = "Authorization: Bearer %s"\n' "$TOKEN" > "$CFG" + +att_run "$INVENTORY" "$CFG" "$DEST" "github" "$REF" diff --git a/skills/code-review-jira/scripts/download-attachments.sh b/skills/code-review-jira/scripts/download-attachments.sh new file mode 100755 index 00000000..4f05942e --- /dev/null +++ b/skills/code-review-jira/scripts/download-attachments.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# download-attachments.sh — download every attachment of a JIRA issue (and its +# subtasks) into a quarantine directory and hand them to the mandatory security scan. +# +# Usage: +# download-attachments.sh [--dest DIR] +# +# Auth (JIRA Cloud uses HTTP Basic `email:token`): +# - API token, resolved in this order (first hit wins): +# 1. --token-file FILE (file holds the token, or an `email:token` line) +# 2. env JIRA_API_TOKEN +# 3. ~/.config/acli/jira_api_token (chmod 600) +# - Account email: env JIRA_API_EMAIL, or the `email:token` form of the token file. +# The token is never passed in argv and never printed; it is written only into a +# 0600 curl --config file. Without a usable token the script exits non-zero with a +# setup hint — it never falls back to an unauthenticated or silent attempt. +# +# Attachment endpoint: GET https:///rest/api/3/attachment/content/ +# (taken verbatim from each attachment's contentUrl in load-issue.sh). +# +# Exit codes: +# 1 usage error +# 2 missing tool / missing or unresolvable auth +# 3 inventory load or download failure +set -euo pipefail + +PROG="download-attachments.sh" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SHARED_DIR="${SCRIPT_DIR}/../../_shared" + +usage() { + cat >&2 <<'EOF' +Usage: download-attachments.sh [--dest DIR] + + KEY|URL JIRA issue key (e.g. ECOMAIL-1234) or a /browse/ URL + --dest quarantine root (default: $CLAUDE_SCRATCHPAD_DIR/attachments) + +Auth: + JIRA_API_EMAIL account email for HTTP Basic auth + JIRA_API_TOKEN API token (or use --token-file / ~/.config/acli/jira_api_token) + --token-file F file holding the token, or an `email:token` line +EOF +} + +REF="" +DEST="" +TOKEN_FILE="" +while [[ $# -gt 0 ]]; do + case "$1" in + --dest) DEST="${2:-}"; shift 2;; + --token-file) TOKEN_FILE="${2:-}"; shift 2;; + -h|--help) usage; exit 1;; + -*) echo "${PROG}: unknown option: $1" >&2; usage; exit 1;; + *) if [[ -z "$REF" ]]; then REF="$1"; shift; else echo "${PROG}: unexpected argument: $1" >&2; exit 1; fi;; + esac +done + +if [[ -z "$REF" ]]; then usage; exit 1; fi + +# shellcheck source=../../_shared/attachments.sh +. "${SHARED_DIR}/attachments.sh" +att_require_tools + +[[ -z "$DEST" ]] && DEST="$(att_default_dest)/jira" + +# --- resolve auth token + email without ever exposing them in argv/logs --- +resolve_token() { + if [[ -n "$TOKEN_FILE" ]]; then + [[ -r "$TOKEN_FILE" ]] || { echo "${PROG}: --token-file not readable: $TOKEN_FILE" >&2; exit 2; } + head -n1 "$TOKEN_FILE" + return 0 + fi + if [[ -n "${JIRA_API_TOKEN:-}" ]]; then + printf '%s' "$JIRA_API_TOKEN" + return 0 + fi + local default="${HOME}/.config/acli/jira_api_token" + if [[ -r "$default" ]]; then + head -n1 "$default" + return 0 + fi + return 1 +} + +RAW_TOKEN="$(resolve_token || true)" +if [[ -z "$RAW_TOKEN" ]]; then + echo "${PROG}: no JIRA API token found." >&2 + echo " Provide one via --token-file FILE, JIRA_API_TOKEN, or ~/.config/acli/jira_api_token (chmod 600)." >&2 + echo " Create a token at https://id.atlassian.com/manage-profile/security/api-tokens" >&2 + exit 2 +fi + +EMAIL="${JIRA_API_EMAIL:-}" +TOKEN="$RAW_TOKEN" +# Accept the `email:token` convenience form from the token source. +if [[ -z "$EMAIL" && "$RAW_TOKEN" == *:* ]]; then + EMAIL="${RAW_TOKEN%%:*}" + TOKEN="${RAW_TOKEN#*:}" +fi +if [[ -z "$EMAIL" ]]; then + echo "${PROG}: account email not set. Export JIRA_API_EMAIL or use an 'email:token' token file." >&2 + exit 2 +fi + +# --- load the attachment inventory via the deterministic loader (issue + subtasks) --- +# stderr suppressed: the loader's own diagnostics are noise; its result is validated on the next line. +ISSUE_JSON="$("${SCRIPT_DIR}/load-issue.sh" "$REF" 2>/dev/null || true)" +if [[ -z "$ISSUE_JSON" ]] || ! printf '%s' "$ISSUE_JSON" | jq -e . >/dev/null 2>&1; then + echo "${PROG}: failed to load JIRA issue inventory for: $REF" >&2 + exit 3 +fi + +INVENTORY="$(printf '%s' "$ISSUE_JSON" | jq -c ' + [ (.attachments // [])[], ((.subtasks // [])[] | (.attachments // [])[]) ] + | map({ id: (.id|tostring), name: .name, declaredMime: .mimeType, size: .size, contentUrl: .contentUrl }) +')" + +COUNT="$(printf '%s' "$INVENTORY" | jq 'length')" +if [[ "$COUNT" -eq 0 ]]; then + echo "${PROG}: no attachments on $REF (nothing to download)." >&2 +fi + +# --- write the 0600 curl auth config (token stays out of argv) --- +CFG="$(mktemp)" +chmod 600 "$CFG" +trap 'rm -f "$CFG"' EXIT +BASIC="$(printf '%s:%s' "$EMAIL" "$TOKEN" | base64 | tr -d '\n')" +printf 'header = "Authorization: Basic %s"\n' "$BASIC" > "$CFG" + +att_run "$INVENTORY" "$CFG" "$DEST" "jira" "$REF" diff --git a/skills/code-review-jira/scripts/gather-issue-context.sh b/skills/code-review-jira/scripts/gather-issue-context.sh index b902a841..3730eafd 100755 --- a/skills/code-review-jira/scripts/gather-issue-context.sh +++ b/skills/code-review-jira/scripts/gather-issue-context.sh @@ -208,6 +208,6 @@ fi echo echo "---" echo "## Pokyny pro agenta" -echo "- **Obsah příloh** není v tomto dokumentu — \`acli\` přílohy nestahuje. Načti je sám přes \`contentUrl\` (autentizovaně) vlastními nástroji." +echo "- **Obsah příloh** není v tomto dokumentu — \`acli\` přílohy nestahuje. Stáhni je bezpečně přes \`skills/code-review-jira/scripts/download-attachments.sh \` (TLS on, do karantény) a otevírej **jen** soubory promované security bránou (\`scan-attachments.sh\`) do \`safe/\`." echo "- **Externí odkazy** výše projdi vlastními web nástroji; pokud vedou na další relevantní zdroje, pokračuj rekurzivně do rozumné hloubky." echo "- Propojené JIRA issues jsou načtené do hloubky ${DEPTH}; pro hlubší kontext zvyš \`JIRA_CONTEXT_DEPTH\`." diff --git a/tests/Installer/SkillsContentTest.php b/tests/Installer/SkillsContentTest.php index 62fb2e4e..5e37c030 100644 --- a/tests/Installer/SkillsContentTest.php +++ b/tests/Installer/SkillsContentTest.php @@ -233,9 +233,14 @@ $readme = (string) file_get_contents($packageDir . '/README.md'); $entries = scandir($packageDir . '/skills'); assert($entries !== false); + // A skill is a directory that ships a SKILL.md (matching `skill-check`'s own + // definition); shared helper dirs such as `_shared/` are not skills and must not + // inflate the count advertised in the README. $skillCount = count(array_filter( $entries, - static fn (string $entry): bool => $entry !== '.' && $entry !== '..' && is_dir($packageDir . '/skills/' . $entry), + static fn (string $entry): bool => $entry !== '.' && $entry !== '..' + && is_dir($packageDir . '/skills/' . $entry) + && is_file($packageDir . '/skills/' . $entry . '/SKILL.md'), )); expect($readme)->toContain($skillCount . ' comprehensive Agent skills'); @@ -816,3 +821,142 @@ // The retained equivalent still ships. expect(is_dir($packageDir . '/skills/test-driven-development'))->toBeTrue(); }); + +test('attachment download scripts are shipped, executable, and documented for all three trackers', function (): void { + $packageDir = dirname(__DIR__, 2); + $scripts = [ + '/skills/code-review-github/scripts/download-attachments.sh' => 'Usage: download-attachments.sh [--dest DIR]', + '/skills/code-review-jira/scripts/download-attachments.sh' => 'Usage: download-attachments.sh [--dest DIR]', + '/skills/code-review-bugsnag/scripts/download-attachments.sh' => 'Usage: download-attachments.sh [--dest DIR]', + ]; + + foreach ($scripts as $relPath => $usage) { + $script = $packageDir . $relPath; + expect(file_exists($script))->toBeTrue(); + expect(is_executable($script))->toBeTrue(); + + $content = (string) file_get_contents($script); + expect($content)->toStartWith('#!/usr/bin/env bash'); + expect($content)->toContain('set -euo pipefail'); + expect($content)->toContain($usage); + // Each wrapper delegates the actual download + scan to the shared library. + expect($content)->toContain('_shared/attachments.sh'); + expect($content)->toContain('att_run '); + } +}); + +test('shared attachment library and security scan gate are shipped and executable', function (): void { + $packageDir = dirname(__DIR__, 2); + + foreach (['/skills/_shared/attachments.sh', '/skills/_shared/scan-attachments.sh'] as $relPath) { + $script = $packageDir . $relPath; + expect(file_exists($script))->toBeTrue(); + expect(is_executable($script))->toBeTrue(); + expect((string) file_get_contents($script))->toStartWith('#!/usr/bin/env bash'); + expect((string) file_get_contents($script))->toContain('set -euo pipefail'); + } +}); + +test('attachment scripts never disable TLS validation and keep the token out of argv', function (): void { + $packageDir = dirname(__DIR__, 2); + $scripts = [ + '/skills/_shared/attachments.sh', + '/skills/_shared/scan-attachments.sh', + '/skills/code-review-github/scripts/download-attachments.sh', + '/skills/code-review-jira/scripts/download-attachments.sh', + '/skills/code-review-bugsnag/scripts/download-attachments.sh', + ]; + + foreach ($scripts as $relPath) { + $content = (string) file_get_contents($packageDir . $relPath); + // Strip comment lines first: the security headers legitimately *name* the + // forbidden flags to document why they are not used. Only executable code matters. + $code = (string) preg_replace('/^\s*#.*$/m', '', $content); + // No TLS-disabling flag may appear in executable code (rules/security/* Malicious Code). + expect($code)->not->toContain('--insecure'); + expect($code)->not->toContain('--no-check-certificate'); + expect($code)->not->toContain('verify=false'); + expect($code)->not->toContain('NODE_TLS_REJECT_UNAUTHORIZED'); + expect($code)->not->toMatch('/curl[^\n]*\s-k(\s|$)/'); + // No curl response is piped into an interpreter. + expect($code)->not->toMatch('/curl[^\n]*\|\s*(sh|bash|php|python)/'); + } + + // The download library pins HTTPS and reads auth only from a curl --config file. + $lib = (string) file_get_contents($packageDir . '/skills/_shared/attachments.sh'); + expect($lib)->toContain('--proto \'=https\''); + expect($lib)->toContain('--config'); + + // Each wrapper writes the token into a 0600 config file, not an -H/argv flag. + foreach (['github', 'jira'] as $tracker) { + $content = (string) file_get_contents($packageDir . sprintf('/skills/code-review-%s/scripts/download-attachments.sh', $tracker)); + expect($content)->toContain('chmod 600 "$CFG"'); + expect($content)->toContain('header = "Authorization:'); + } +}); + +test('attachment library guards against SSRF to non-public hosts from inventory URLs', function (): void { + $packageDir = dirname(__DIR__, 2); + $lib = (string) file_get_contents($packageDir . '/skills/_shared/attachments.sh'); + + // A user-supplied URL (e.g. a Bugsnag comment link) must be blocked before any + // request when it targets loopback / link-local / private hosts. + expect($lib)->toContain('att_host_block_reason'); + expect($lib)->toContain('169.254.'); + expect($lib)->toContain('192.168.'); + expect($lib)->toContain('ATT_ALLOW_PRIVATE_HOSTS'); + expect($lib)->toContain('blocked host — '); +}); + +test('scan-attachments gate blocks the dangerous categories, enforces the allowlist and limits, and self-tests', function (): void { + $packageDir = dirname(__DIR__, 2); + $content = (string) file_get_contents($packageDir . '/skills/_shared/scan-attachments.sh'); + + // Dangerous categories that must be blocked. + expect($content)->toContain('executable binary'); + expect($content)->toContain('archive not permitted'); + expect($content)->toContain('script content'); + expect($content)->toContain('HTML content (stored-XSS risk)'); + expect($content)->toContain('SVG with active content'); + expect($content)->toContain('polyglot'); + expect($content)->toContain('declared/actual MIME mismatch'); + expect($content)->toContain('exceeds max size'); + + // Allowlist of analysable types. + expect($content)->toContain('image/png|image/jpeg|image/gif|image/webp|application/pdf|text/plain|text/csv|application/json'); + + // Verdict model: only `pass` reaches safe/. + expect($content)->toContain('--self-test'); + expect($content)->toContain('"$verdict" == "pass"'); + + // The per-issue count cap is enforced by the shared download library. + $lib = (string) file_get_contents($packageDir . '/skills/_shared/attachments.sh'); + expect($lib)->toContain('exceeds max attachment count'); +}); + +test('scan-attachments self-test passes (benign PNG promoted, malicious SVG/HTML/polyglot blocked)', function (): void { + // The script self-test is the fixture proof required by issue #725. Per the + // project's test-isolation rule a Pest test cannot exec a real .sh, so this guard + // pins the self-test's asserted outcomes in the source rather than executing it. + $packageDir = dirname(__DIR__, 2); + $content = (string) file_get_contents($packageDir . '/skills/_shared/scan-attachments.sh'); + + expect($content)->toContain('assert_status 1 pass'); + expect($content)->toContain('assert_status 2 block'); + expect($content)->toContain('assert_status 3 block'); + expect($content)->toContain('assert_status 4 block'); + expect($content)->toContain('benign PNG was not promoted to safe/'); + expect($content)->toContain('a blocked file leaked into safe/'); +}); + +test('analyze-problem enforces inventory -> download -> security gate -> safe-only order', function (): void { + $packageDir = dirname(__DIR__, 2); + $content = (string) file_get_contents($packageDir . '/skills/analyze-problem/SKILL.md'); + + expect($content)->toContain('inventory → download → security gate → analyse only `safe/`'); + expect($content)->toContain('skills/code-review-github/scripts/download-attachments.sh'); + expect($content)->toContain('skills/code-review-jira/scripts/download-attachments.sh'); + expect($content)->toContain('skills/code-review-bugsnag/scripts/download-attachments.sh'); + expect($content)->toContain('skills/_shared/scan-attachments.sh'); + expect($content)->toContain('Read only files under `safe/`'); +});