diff --git a/examples/custom-stack-template/compliance-release/skills/license-audit/bin/audit.sh b/examples/custom-stack-template/compliance-release/skills/license-audit/bin/audit.sh index 1da5510..4402b2a 100755 --- a/examples/custom-stack-template/compliance-release/skills/license-audit/bin/audit.sh +++ b/examples/custom-stack-template/compliance-release/skills/license-audit/bin/audit.sh @@ -1,23 +1,171 @@ #!/usr/bin/env bash -# audit.sh — license-audit skill (placeholder for PR 1 of CSE v1). +# audit.sh — license-audit skill helper. # -# PR 2 of the Custom Stack Examples v1 round replaces this body with -# real license classification across npm/pip/go manifests. PR 1 ships -# only the scaffolding so the static contract validates. +# Walks direct dependencies of the project in cwd and classifies each +# declared license into one of four families: permissive, weak +# copyleft, strong copyleft, unknown. Emits a JSON object with a +# `counts` map and a `flagged` list of strong-copyleft hits. # -# When PR 2 lands, the helper must: -# - Detect the project stack from package.json | requirements.txt | -# pyproject.toml | go.mod. -# - Classify each direct dependency's license into permissive, -# weak_copyleft, strong_copyleft, or unknown. -# - Print a JSON object on stdout: { counts, flagged }. -# - Exit 0 always; the artifact's summary.status carries OK/WARN/BLOCKED. -set -e - -cat <<'JSON' -{ - "counts": { "total": 0, "permissive": 0, "weak_copyleft": 0, "strong_copyleft": 0, "unknown": 0 }, - "flagged": [], - "_placeholder": "license-audit/bin/audit.sh — PR 1 stub. Real behavior lands in PR 2." +# Stack detection is automatic: the helper looks for package.json, +# requirements.txt, pyproject.toml, or go.mod in the current directory. +# Pass an optional positional argument (node|python|go) to force a +# specific stack when more than one manifest is present. +# +# This is a release-hygiene check, not a production license scanner. +# Direct dependencies only; transitive deps are out of scope. For +# Node, license metadata is read from each module's package.json +# under node_modules/ when available; otherwise the dep is recorded +# as unknown. Python and Go manifests do not declare license metadata, +# so deps from those stacks always classify as unknown unless the +# user runs a deeper auditor. +# +# Exit always 0; the artifact's summary.status carries OK/WARN/BLOCKED +# (computed from the counts and flagged list by the calling skill). +set -eu + +# ─── Stack detection ───────────────────────────────────────── +detect_stack() { + if [ -f package.json ]; then printf 'node'; return; fi + if [ -f pyproject.toml ] || [ -f requirements.txt ]; then printf 'python'; return; fi + if [ -f go.mod ]; then printf 'go'; return; fi + printf 'none' +} + +STACK="${1:-$(detect_stack)}" + +# ─── License family classifier ─────────────────────────────── +classify() { + local lic="$1" + lic=$(printf '%s' "$lic" | tr '[:lower:]' '[:upper:]' | tr -d ' "') + case "$lic" in + MIT|BSD*|APACHE*|ISC|0BSD|UNLICENSE|CC0*) printf 'permissive' ;; + LGPL*|MPL*|EPL*) printf 'weak_copyleft' ;; + GPL*|AGPL*) printf 'strong_copyleft' ;; + *) printf 'unknown' ;; + esac +} + +# ─── Per-stack scanners ────────────────────────────────────── +scan_node() { + [ -f package.json ] || return 0 + jq -r '(.dependencies // {}) + (.devDependencies // {}) | keys[]' package.json 2>/dev/null | while read -r dep; do + [ -z "$dep" ] && continue + local mod_pkg="node_modules/$dep/package.json" + local lic="unknown" + if [ -f "$mod_pkg" ]; then + lic=$(jq -r ' + .license // ( + .licenses // [] + | if type == "array" and length > 0 then (.[0].type // "unknown") + else "unknown" end + ) + ' "$mod_pkg" 2>/dev/null) + [ -z "$lic" ] || [ "$lic" = "null" ] && lic="unknown" + fi + printf '%s\t%s\n' "$dep" "$lic" + done +} + +scan_python() { + if [ -f pyproject.toml ]; then + grep -E '^[a-zA-Z0-9_-]+[ ]*=|^"[^"]+' pyproject.toml 2>/dev/null | head -50 | \ + awk -F'[="]' '{print $1}' | sed 's/[[:space:]]//g' | while read -r dep; do + [ -z "$dep" ] && continue + printf '%s\tunknown\n' "$dep" + done + elif [ -f requirements.txt ]; then + grep -vE '^#|^$' requirements.txt 2>/dev/null | while read -r line; do + local dep="${line%%[<>=~!]*}" + dep=$(printf '%s' "$dep" | tr -d '[:space:]') + [ -z "$dep" ] && continue + printf '%s\tunknown\n' "$dep" + done + fi +} + +scan_go() { + [ -f go.mod ] || return 0 + # go.mod has two require forms: + # 1. Block: `require ( \n ... )` -> indented module names. + # 2. Single: `require ` -> top-level statement. + # Cover both; the original implementation only handled the block + # form and silently dropped single-line require statements. + awk ' + BEGIN { in_block = 0 } + /^require[[:space:]]*\(/ { in_block = 1; next } + /^\)/ { in_block = 0; next } + in_block && /^[[:space:]]+[a-zA-Z0-9._\/-]+/ { + # Strip indirect comments and emit module name (column 1 after trim). + sub(/^[[:space:]]+/, ""); print $1; next + } + /^require[[:space:]]+[a-zA-Z0-9._\/-]+/ { + # Single-line: `require ` -> module is column 2. + print $2 + } + ' go.mod 2>/dev/null | while read -r dep; do + [ -z "$dep" ] && continue + printf '%s\tunknown\n' "$dep" + done } -JSON + +# ─── Run and aggregate ─────────────────────────────────────── +case "$STACK" in + node) RAW=$(scan_node) ;; + python) RAW=$(scan_python) ;; + go) RAW=$(scan_go) ;; + none) + # No supported manifest in cwd. Emit an empty result rather than + # erroring; the calling skill marks status=WARN with a clear + # next_action ("no supported manifest found"). + RAW="" + ;; + *) echo "unknown stack: $STACK" >&2; exit 2 ;; +esac + +PERMISSIVE=0 +WEAK=0 +STRONG=0 +UNKNOWN=0 +FLAGGED_LIST="" + +while IFS=$'\t' read -r name license; do + [ -z "$name" ] && continue + family=$(classify "$license") + case "$family" in + permissive) PERMISSIVE=$((PERMISSIVE + 1)) ;; + weak_copyleft) WEAK=$((WEAK + 1)) ;; + strong_copyleft) + STRONG=$((STRONG + 1)) + FLAGGED_LIST="${FLAGGED_LIST}${name}|${license} +" + ;; + unknown) UNKNOWN=$((UNKNOWN + 1)) ;; + esac +done <<< "$RAW" + +FLAGGED="[]" +if [ -n "$FLAGGED_LIST" ]; then + FLAGGED=$(printf '%s' "$FLAGGED_LIST" | awk -F'|' 'NF==2 {printf "{\"name\":\"%s\",\"license\":\"%s\"}\n",$1,$2}' | jq -s '.') +fi + +TOTAL=$((PERMISSIVE + WEAK + STRONG + UNKNOWN)) + +jq -n \ + --arg stack "$STACK" \ + --argjson total "$TOTAL" \ + --argjson permissive "$PERMISSIVE" \ + --argjson weak "$WEAK" \ + --argjson strong "$STRONG" \ + --argjson unknown "$UNKNOWN" \ + --argjson flagged "$FLAGGED" \ + '{ + stack: $stack, + counts: { + total: $total, + permissive: $permissive, + weak_copyleft: $weak, + strong_copyleft: $strong, + unknown: $unknown + }, + flagged: $flagged + }' diff --git a/examples/custom-stack-template/compliance-release/skills/license-audit/bin/smoke.sh b/examples/custom-stack-template/compliance-release/skills/license-audit/bin/smoke.sh index 6177af7..e3e23a2 100755 --- a/examples/custom-stack-template/compliance-release/skills/license-audit/bin/smoke.sh +++ b/examples/custom-stack-template/compliance-release/skills/license-audit/bin/smoke.sh @@ -1,10 +1,18 @@ #!/usr/bin/env bash -# smoke.sh — license-audit smoke check (placeholder for PR 1). +# smoke.sh — license-audit runtime sanity check. # -# PR 2 replaces this with a real /tmp project + manifests check. -# PR 1 ships only the file so the static contract validates that -# every skill folder has a smoke.sh. -set -e +# Sets up three minimal projects in /tmp (Node with one MIT dep +# installed under node_modules/, Python with a single requirements.txt +# entry, Go with a single go.mod require). Asserts: +# 1. Stack auto-detection picks the right manifest in each case. +# 2. The Node case classifies an MIT dep as permissive (read from +# node_modules//package.json). +# 3. Python and Go cases classify their unknown-license deps as +# `unknown` (the helper has no way to know without a deeper +# auditor). +# 4. A GPL dep installed under node_modules/ classifies as +# strong_copyleft and lands in `flagged`. +set -eu SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)" AUDIT="$SKILL_DIR/bin/audit.sh" @@ -13,17 +21,116 @@ if [ ! -x "$AUDIT" ]; then echo "FAIL: $AUDIT is not executable" >&2 exit 1 fi - if ! command -v jq >/dev/null 2>&1; then echo "FAIL: jq is required for the smoke check" >&2 exit 1 fi -out=$( "$AUDIT" 2>/dev/null ) -if ! printf '%s' "$out" | jq -e '.counts' >/dev/null 2>&1; then - echo "FAIL: audit.sh did not emit a JSON object with .counts" >&2 - echo "$out" >&2 - exit 1 +tmp=$(mktemp -d /tmp/license-audit-smoke.XXXXXX) +trap 'rm -rf "$tmp"' EXIT +fail=0 + +# ─── Case 1: Node + permissive MIT dep installed ──────────── +mkdir -p "$tmp/node-permissive/node_modules/lodash" +cat > "$tmp/node-permissive/package.json" <<'PKG' +{ "name": "smoke-node", "dependencies": { "lodash": "4.17.21" } } +PKG +cat > "$tmp/node-permissive/node_modules/lodash/package.json" <<'PKG' +{ "name": "lodash", "version": "4.17.21", "license": "MIT" } +PKG +out=$( cd "$tmp/node-permissive" && "$AUDIT" 2>&1 ) +if echo "$out" | jq -e '.stack == "node" and .counts.permissive == 1 and .counts.strong_copyleft == 0' >/dev/null 2>&1; then + echo " ok node MIT dep classifies as permissive" +else + echo "FAIL: node MIT case wrong" + echo "$out" + fail=1 +fi + +# ─── Case 2: Node + GPL dep installed ─────────────────────── +mkdir -p "$tmp/node-gpl/node_modules/some-gpl-thing" +cat > "$tmp/node-gpl/package.json" <<'PKG' +{ "name": "smoke-gpl", "dependencies": { "some-gpl-thing": "1.0.0" } } +PKG +cat > "$tmp/node-gpl/node_modules/some-gpl-thing/package.json" <<'PKG' +{ "name": "some-gpl-thing", "version": "1.0.0", "license": "GPL-3.0" } +PKG +out=$( cd "$tmp/node-gpl" && "$AUDIT" 2>&1 ) +if echo "$out" | jq -e '.counts.strong_copyleft == 1 and (.flagged | length) == 1 and .flagged[0].name == "some-gpl-thing"' >/dev/null 2>&1; then + echo " ok GPL dep classifies as strong_copyleft and lands in flagged" +else + echo "FAIL: node GPL case wrong" + echo "$out" + fail=1 +fi + +# ─── Case 3: Python requirements.txt ──────────────────────── +printf 'requests==2.31.0\nflask>=2.0\n' > "$tmp/py-req.txt" +mkdir -p "$tmp/python-req" +cp "$tmp/py-req.txt" "$tmp/python-req/requirements.txt" +out=$( cd "$tmp/python-req" && "$AUDIT" 2>&1 ) +if echo "$out" | jq -e '.stack == "python" and .counts.unknown >= 2' >/dev/null 2>&1; then + echo " ok python requirements.txt deps classify as unknown" +else + echo "FAIL: python case wrong" + echo "$out" + fail=1 +fi + +# ─── Case 4: Go go.mod ────────────────────────────────────── +mkdir -p "$tmp/go-mod" +cat > "$tmp/go-mod/go.mod" <<'GOMOD' +module smoke + +go 1.21 + +require ( + github.com/stretchr/testify v1.8.4 + github.com/spf13/cobra v1.7.0 +) +GOMOD +out=$( cd "$tmp/go-mod" && "$AUDIT" 2>&1 ) +if echo "$out" | jq -e '.stack == "go" and .counts.total >= 2 and .counts.unknown >= 2' >/dev/null 2>&1; then + echo " ok go module deps classify as unknown" +else + echo "FAIL: go case wrong" + echo "$out" + fail=1 fi -echo "OK: license-audit placeholder smoke passed (PR 2 wires the real behavior)" +# ─── Case 5: No manifest in cwd ───────────────────────────── +mkdir -p "$tmp/empty" +out=$( cd "$tmp/empty" && "$AUDIT" 2>&1 ) +if echo "$out" | jq -e '.stack == "none" and .counts.total == 0' >/dev/null 2>&1; then + echo " ok empty project classifies as stack=none with zero counts" +else + echo "FAIL: empty case wrong" + echo "$out" + fail=1 +fi + +# ─── Case 6: Go single-line require form ──────────────────── +# A common minimal go.mod uses `require ` without +# a require block. The original scanner only matched indented entries +# inside `require (...)`, dropping single-line deps silently. +mkdir -p "$tmp/go-single" +cat > "$tmp/go-single/go.mod" <<'GOMOD' +module smoke + +go 1.21 + +require github.com/spf13/cobra v1.8.0 +GOMOD +out=$( cd "$tmp/go-single" && "$AUDIT" 2>&1 ) +if echo "$out" | jq -e '.stack == "go" and .counts.total == 1 and .counts.unknown == 1' >/dev/null 2>&1; then + echo " ok go single-line require captures the dep" +else + echo "FAIL: go single-line case wrong (counts.total should be 1)" + echo "$out" + fail=1 +fi + +if [ "$fail" -eq 0 ]; then + echo "OK: license-audit smoke passed (6 cases)" +fi +exit $fail diff --git a/examples/custom-stack-template/compliance-release/skills/privacy-check/bin/check.sh b/examples/custom-stack-template/compliance-release/skills/privacy-check/bin/check.sh index 92fdd77..22fa046 100755 --- a/examples/custom-stack-template/compliance-release/skills/privacy-check/bin/check.sh +++ b/examples/custom-stack-template/compliance-release/skills/privacy-check/bin/check.sh @@ -1,15 +1,155 @@ #!/usr/bin/env bash -# check.sh — privacy-check skill (placeholder for PR 1 of CSE v1). +# check.sh — privacy-check skill helper. # -# PR 2 replaces this body with the real release-hygiene scan -# (personal-data fields, telemetry imports, missing privacy note). -# PR 1 ships only the scaffolding so the static contract validates. -set -e +# Release-hygiene scan. Reads files from cwd and surfaces three +# classes of signal: +# 1. personal_data — code mentions email/name/phone/address/payment/ +# token/api_key/file upload in source files under common +# locations (src/, app/, pages/, server/, api/). +# 2. telemetry — code imports analytics/tracking/telemetry +# libraries (analytics, tracking, telemetry, segment, posthog, +# ga, mixpanel, sentry). +# 3. env_template — env templates (.env.example, .env.sample, +# .env.template) reference keys that hint at collection +# (anything containing email, phone, payment, secret, token). +# +# Then checks whether a privacy note exists: PRIVACY.md at repo root, +# or a "Privacy" / "Data" / "Privacidad" H2 in README.md, or +# TELEMETRY.md when the only signal class is telemetry. +# +# This is NOT a legal review. It is a deterministic release-hygiene +# check that catches the easy misses. It never reads .env, .env.local, +# .env.production, or credential JSON (the bash guard already blocks +# those at the host layer). +# +# Output: JSON object with `signals` and `missing`. The calling skill +# maps these to summary.status: +# OK — no signals, or signals + privacy note present. +# WARN — signals present and privacy note missing. +# BLOCKED — reserved for clearly unsafe patterns; this helper does +# not emit BLOCKED on its own (the composer in +# /release-readiness escalates if needed). +set -eu + +# Source roots to scan. Bounded to common app-code locations so the +# scanner doesn't walk node_modules / .venv / vendor. +SOURCE_ROOTS="src app pages server api lib" + +# Personal-data field markers. Match as whole-word tokens to avoid +# false positives on identifiers that happen to contain "email" as a +# substring (e.g. "emailing-list-name"). The `name` token is a known +# false-positive magnet (it appears in lots of code unrelated to user +# collection); we keep it because the SKILL contract says we cover +# it, and the user triages the per-file evidence list. +PERSONAL_RE='\b(email|name|phone|address|payment|credit_?card|ssn|api[_-]?key|access[_-]?token|file[_-]?upload)\b' + +# Telemetry libraries. These are import-statement substrings; +# language-agnostic so the same pattern catches `from sentry`, +# `require('posthog')`, `import segment from`, etc. `ga` (Google +# Analytics) is short and noisy, but the SKILL contract names it +# explicitly; the user triages the per-file evidence list. +TELEMETRY_RE='\b(analytics|tracking|telemetry|segment|posthog|ga|mixpanel|sentry)\b' + +# Env-template indicators that hint at collection. Matches against +# variable names like EMAIL_API_KEY or USER_PHONE_NUMBER. +ENV_HINT_RE='(EMAIL|PHONE|PAYMENT|SECRET|TOKEN|API[_-]?KEY)' + +scan_personal_data() { + for root in $SOURCE_ROOTS; do + [ -d "$root" ] || continue + grep -rEn "$PERSONAL_RE" "$root" 2>/dev/null | head -20 | while IFS=: read -r file line evidence; do + [ -z "$file" ] && continue + # Extract just the matching token for evidence. + token=$(printf '%s' "$evidence" | grep -oE "$PERSONAL_RE" | head -1) + [ -z "$token" ] && token="(field)" + printf 'personal_data\t%s\t%s\n' "$file" "$token" + done + done +} + +scan_telemetry() { + for root in $SOURCE_ROOTS; do + [ -d "$root" ] || continue + grep -rEn "$TELEMETRY_RE" "$root" 2>/dev/null | head -20 | while IFS=: read -r file line evidence; do + [ -z "$file" ] && continue + token=$(printf '%s' "$evidence" | grep -oiE "$TELEMETRY_RE" | head -1 | tr '[:upper:]' '[:lower:]') + [ -z "$token" ] && token="(library)" + printf 'telemetry\t%s\t%s\n' "$file" "$token" + done + done +} + +scan_env_templates() { + for tmpl in .env.example .env.sample .env.template; do + [ -f "$tmpl" ] || continue + grep -nE "$ENV_HINT_RE" "$tmpl" 2>/dev/null | head -10 | while IFS=: read -r line evidence; do + var=$(printf '%s' "$evidence" | grep -oE '^[A-Z_][A-Z0-9_]*' | head -1) + [ -z "$var" ] && var="(template-key)" + printf 'env_template\t%s\t%s\n' "$tmpl" "$var" + done + done +} + +has_privacy_note() { + [ -f PRIVACY.md ] && return 0 + if [ -f README.md ]; then + if grep -qiE '^##[[:space:]]+(Privacy|Privacidad|Data[[:space:]]+(handling|collection))' README.md; then + return 0 + fi + fi + return 1 +} -cat <<'JSON' -{ - "signals": [], - "missing": [], - "_placeholder": "privacy-check/bin/check.sh — PR 1 stub. Real behavior lands in PR 2." +has_telemetry_doc() { + [ -f TELEMETRY.md ] } -JSON + +# ─── Run scans ───────────────────────────────────────────── +RAW="" +RAW="${RAW}$(scan_personal_data) +" +RAW="${RAW}$(scan_telemetry) +" +RAW="${RAW}$(scan_env_templates) +" + +# Build the signals JSON array. Use jq -s to merge per-line objects. +SIGNALS="[]" +TELEMETRY_ONLY=true +PERSONAL_HIT=false +TELEMETRY_HIT=false +ENV_HIT=false + +if [ -n "$(printf '%s' "$RAW" | tr -d '[:space:]')" ]; then + SIGNALS=$(printf '%s\n' "$RAW" | awk -F'\t' ' + NF == 3 { printf "{\"kind\":\"%s\",\"file\":\"%s\",\"evidence\":\"%s\"}\n", $1, $2, $3 } + ' | jq -s '.') + PERSONAL_HIT=$(echo "$SIGNALS" | jq -r 'any(.kind == "personal_data")') + TELEMETRY_HIT=$(echo "$SIGNALS" | jq -r 'any(.kind == "telemetry")') + ENV_HIT=$(echo "$SIGNALS" | jq -r 'any(.kind == "env_template")') +fi + +# Determine "missing" docs. +MISSING="[]" +ms="" +# Telemetry-only case is satisfied by TELEMETRY.md OR a privacy note. +if [ "$TELEMETRY_HIT" = "true" ] && [ "$PERSONAL_HIT" != "true" ] && [ "$ENV_HIT" != "true" ]; then + if ! has_privacy_note && ! has_telemetry_doc; then + ms="${ms}privacy_note " + fi +elif [ "$PERSONAL_HIT" = "true" ] || [ "$ENV_HIT" = "true" ]; then + if ! has_privacy_note; then + ms="${ms}privacy_note " + fi +fi +if [ -n "$ms" ]; then + MISSING=$(printf '%s' "$ms" | tr ' ' '\n' | grep -v '^$' | jq -R . | jq -s '.') +fi + +jq -n \ + --argjson signals "$SIGNALS" \ + --argjson missing "$MISSING" \ + '{ + signals: $signals, + missing: $missing + }' diff --git a/examples/custom-stack-template/compliance-release/skills/privacy-check/bin/smoke.sh b/examples/custom-stack-template/compliance-release/skills/privacy-check/bin/smoke.sh index de91c2d..a4830fb 100755 --- a/examples/custom-stack-template/compliance-release/skills/privacy-check/bin/smoke.sh +++ b/examples/custom-stack-template/compliance-release/skills/privacy-check/bin/smoke.sh @@ -1,6 +1,16 @@ #!/usr/bin/env bash -# smoke.sh — privacy-check smoke check (placeholder for PR 1). -set -e +# smoke.sh — privacy-check runtime sanity check. +# +# Sets up tmp projects and asserts the scanner behavior: +# 1. clean project (no signals, no missing docs) +# 2. email collection in src/, no PRIVACY.md, no Privacy section +# in README -> personal_data signal + missing privacy_note +# 3. same as 2 but PRIVACY.md present -> signal stays, missing empty +# 4. README.md has "## Privacy" H2 -> satisfies the privacy note +# 5. telemetry-only hit + TELEMETRY.md present -> signal stays, +# no missing entry (TELEMETRY.md is the documented surface) +# 6. .env.example with EMAIL_API_KEY -> env_template signal +set -eu SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)" CHECK="$SKILL_DIR/bin/check.sh" @@ -9,17 +19,138 @@ if [ ! -x "$CHECK" ]; then echo "FAIL: $CHECK is not executable" >&2 exit 1 fi - if ! command -v jq >/dev/null 2>&1; then echo "FAIL: jq is required for the smoke check" >&2 exit 1 fi -out=$( "$CHECK" 2>/dev/null ) -if ! printf '%s' "$out" | jq -e '.signals' >/dev/null 2>&1; then - echo "FAIL: check.sh did not emit a JSON object with .signals" >&2 - echo "$out" >&2 - exit 1 +tmp=$(mktemp -d /tmp/privacy-check-smoke.XXXXXX) +trap 'rm -rf "$tmp"' EXIT +fail=0 + +# ─── Case 1: clean project ────────────────────────────────── +mkdir -p "$tmp/clean/src" +echo 'export const ok = true;' > "$tmp/clean/src/index.js" +out=$( cd "$tmp/clean" && "$CHECK" 2>&1 ) +if echo "$out" | jq -e '(.signals | length) == 0 and (.missing | length) == 0' >/dev/null 2>&1; then + echo " ok clean project: no signals, no missing" +else + echo "FAIL: clean case wrong"; echo "$out"; fail=1 +fi + +# ─── Case 2: email collection, no privacy note ────────────── +mkdir -p "$tmp/leak/src" +cat > "$tmp/leak/src/signup.js" <<'JS' +const form = { email: input.email, name: input.name }; +JS +out=$( cd "$tmp/leak" && "$CHECK" 2>&1 ) +if echo "$out" | jq -e ' + (.signals | any(.kind == "personal_data")) + and (.missing | index("privacy_note") != null) +' >/dev/null 2>&1; then + echo " ok email collection without privacy note flags personal_data + missing" +else + echo "FAIL: leak case wrong"; echo "$out"; fail=1 +fi + +# ─── Case 3: same with PRIVACY.md present ─────────────────── +mkdir -p "$tmp/with-privacy-md/src" +cp "$tmp/leak/src/signup.js" "$tmp/with-privacy-md/src/signup.js" +echo "# Privacy" > "$tmp/with-privacy-md/PRIVACY.md" +out=$( cd "$tmp/with-privacy-md" && "$CHECK" 2>&1 ) +if echo "$out" | jq -e ' + (.signals | any(.kind == "personal_data")) + and (.missing | length) == 0 +' >/dev/null 2>&1; then + echo " ok PRIVACY.md satisfies the privacy_note requirement" +else + echo "FAIL: PRIVACY.md case wrong"; echo "$out"; fail=1 +fi + +# ─── Case 4: README "## Privacy" section satisfies the note ─ +mkdir -p "$tmp/with-readme-privacy/src" +cp "$tmp/leak/src/signup.js" "$tmp/with-readme-privacy/src/signup.js" +cat > "$tmp/with-readme-privacy/README.md" <<'MD' +# App +## Privacy +We collect email for sign-in only. +MD +out=$( cd "$tmp/with-readme-privacy" && "$CHECK" 2>&1 ) +if echo "$out" | jq -e '(.missing | length) == 0' >/dev/null 2>&1; then + echo " ok README '## Privacy' H2 satisfies the privacy note" +else + echo "FAIL: README privacy case wrong"; echo "$out"; fail=1 fi -echo "OK: privacy-check placeholder smoke passed (PR 2 wires the real behavior)" +# ─── Case 5: telemetry-only with TELEMETRY.md ─────────────── +mkdir -p "$tmp/telemetry-doc/src" +cat > "$tmp/telemetry-doc/src/track.js" <<'JS' +import sentry from "sentry"; +sentry.init(); +JS +echo "# Telemetry" > "$tmp/telemetry-doc/TELEMETRY.md" +out=$( cd "$tmp/telemetry-doc" && "$CHECK" 2>&1 ) +if echo "$out" | jq -e ' + (.signals | any(.kind == "telemetry")) + and (.signals | any(.kind == "personal_data") | not) + and (.missing | length) == 0 +' >/dev/null 2>&1; then + echo " ok telemetry-only with TELEMETRY.md does not trigger missing privacy_note" +else + echo "FAIL: telemetry-doc case wrong"; echo "$out"; fail=1 +fi + +# ─── Case 6: env template hint ────────────────────────────── +mkdir -p "$tmp/env-tmpl" +cat > "$tmp/env-tmpl/.env.example" <<'ENV' +EMAIL_API_KEY=sk_test_replace_me +APP_NAME=demo +ENV +out=$( cd "$tmp/env-tmpl" && "$CHECK" 2>&1 ) +if echo "$out" | jq -e ' + (.signals | any(.kind == "env_template" and (.evidence | startswith("EMAIL")))) +' >/dev/null 2>&1; then + echo " ok .env.example with EMAIL_API_KEY flags env_template" +else + echo "FAIL: env_template case wrong"; echo "$out"; fail=1 +fi + +# ─── Case 7: name-only collection ────────────────────────── +# Spec contract names `name` as a personal-data field. A signup form +# that only collects name must trigger personal_data even when no +# email field is present. +mkdir -p "$tmp/name-only/src" +cat > "$tmp/name-only/src/profile.js" <<'JS' +const profile = { name: req.body.name }; +JS +out=$( cd "$tmp/name-only" && "$CHECK" 2>&1 ) +if echo "$out" | jq -e ' + (.signals | any(.kind == "personal_data" and (.evidence == "name"))) +' >/dev/null 2>&1; then + echo " ok name-only collection triggers personal_data" +else + echo "FAIL: name-only case wrong"; echo "$out"; fail=1 +fi + +# ─── Case 8: telemetry library `ga` ───────────────────────── +# Spec contract names `ga` (Google Analytics) as a telemetry library. +# A short token is noisy but the SKILL claims coverage; the smoke +# locks the claim against quiet drift. +mkdir -p "$tmp/ga/src" +cat > "$tmp/ga/src/track.js" <<'JS' +import ga from 'react-ga'; +ga('send', 'pageview'); +JS +out=$( cd "$tmp/ga" && "$CHECK" 2>&1 ) +if echo "$out" | jq -e ' + (.signals | any(.kind == "telemetry" and (.evidence == "ga"))) +' >/dev/null 2>&1; then + echo " ok ga import triggers telemetry signal" +else + echo "FAIL: ga case wrong"; echo "$out"; fail=1 +fi + +if [ "$fail" -eq 0 ]; then + echo "OK: privacy-check smoke passed (8 cases)" +fi +exit $fail diff --git a/examples/custom-stack-template/compliance-release/skills/release-readiness/SKILL.md b/examples/custom-stack-template/compliance-release/skills/release-readiness/SKILL.md index b61237f..12ce6dd 100644 --- a/examples/custom-stack-template/compliance-release/skills/release-readiness/SKILL.md +++ b/examples/custom-stack-template/compliance-release/skills/release-readiness/SKILL.md @@ -38,12 +38,19 @@ SKILL_DIR="${SKILL_DIR:-$HOME/.claude/skills/release-readiness}" "$SKILL_DIR/bin/summarize.sh" ``` -The helper reads each upstream artifact (where present), maps each to a `check` entry, and computes a rolled-up status: +The helper reads each upstream artifact through `bin/find-artifact.sh --verify` and maps each to a `check` entry. Per-check status: - **`MISSING`** for any upstream whose artifact is absent. -- **`BLOCKED`** for the rollup if any upstream is `BLOCKED`. -- **`WARN`** for the rollup if any upstream is `WARN` (and none is `BLOCKED`). -- **`OK`** only when all five upstreams are present and none is `WARN` or `BLOCKED`. +- **`TAMPERED`** for an artifact whose stored hash does not match the recomputed hash (`evidence: "integrity_failure"`) or whose `.integrity` field is absent (`evidence: "missing_integrity"`). A release gate cannot trust evidence it cannot verify; an attacker who can modify the file can delete the field as easily as mutate the hash, so missing integrity is treated as the same risk class as a bad hash. +- **`BLOCKED`** when the upstream's `summary.status` is `BLOCKED`. +- **`WARN`** when the upstream's `summary.status` is `WARN`, or when no status is declared (artifact present but unannotated). +- **`OK`** when the upstream's `summary.status` is `OK` and integrity verifies. + +Rollup is monotonic worst-case: + +- Any `BLOCKED`, `TAMPERED`, or `MISSING` per-check entry forces the rollup to **`BLOCKED`**. +- Otherwise, any `WARN` per-check entry rolls up to **`WARN`**. +- Otherwise, **`OK`**. ### 3. Save the artifact @@ -64,6 +71,7 @@ Prefix the status (`OK`, `WARN`, `BLOCKED`) and surface the most actionable next ## Gotchas - This skill **never runs `/ship`**, never opens a PR, never commits, never deploys. It only composes evidence into a decision. -- Missing upstreams are explicit. If `qa` has no artifact, the rollup is `BLOCKED` for "QA evidence missing" — not `OK` with a quiet gap. The whole point of the gate is to surface that exactly. -- The status rollup is monotonic: once any upstream is `BLOCKED`, the composer cannot soften the rollup to `WARN`. The user must explicitly resolve the blocker. +- Missing upstreams are explicit. If `qa` has no artifact, the rollup is `BLOCKED` for "QA evidence missing" (not `OK` with a quiet gap). The whole point of the gate is to surface that exactly. +- Tampered upstreams are explicit. An artifact whose stored hash does not match the recomputed content, or whose `.integrity` field is absent, becomes `TAMPERED` (not `OK`). A release gate cannot afford to trust evidence it cannot verify. +- The status rollup is monotonic: once any upstream is `BLOCKED`, `TAMPERED`, or `MISSING`, the composer cannot soften the rollup to `WARN`. The user must explicitly resolve the failure. - `WARN` rollups still allow `/ship` (the composer does not auto-block), but the artifact records the warning and the next-action so the team has a paper trail. diff --git a/examples/custom-stack-template/compliance-release/skills/release-readiness/bin/smoke.sh b/examples/custom-stack-template/compliance-release/skills/release-readiness/bin/smoke.sh index b357a28..2808b44 100755 --- a/examples/custom-stack-template/compliance-release/skills/release-readiness/bin/smoke.sh +++ b/examples/custom-stack-template/compliance-release/skills/release-readiness/bin/smoke.sh @@ -1,25 +1,215 @@ #!/usr/bin/env bash -# smoke.sh — release-readiness smoke check (placeholder for PR 1). -set -e +# smoke.sh — release-readiness runtime sanity check. +# +# Sets up tmp projects with various upstream artifact configurations +# and asserts the composer's rollup logic: +# 1. all five upstreams OK -> rollup OK +# 2. one upstream WARN, rest OK -> rollup WARN +# 3. one upstream BLOCKED -> rollup BLOCKED +# 4. one upstream MISSING -> rollup BLOCKED (required upstream) +# 5. mixed (one WARN + one MISSING) -> rollup BLOCKED +# +# Each case writes raw artifacts under .nanostack// and runs +# summarize.sh against that store via NANOSTACK_STORE override. +set -eu SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)" SUMMARIZE="$SKILL_DIR/bin/summarize.sh" +# Find the nanostack repo root by walking up from this skill's location. +# When the skill ships inside the repo (PR 2 dev), bin/find-artifact.sh +# lives a few directories up. When the skill is copied into +# ~/.claude/skills/license-audit, the user-set NANOSTACK_ROOT points +# at the nanostack install. The smoke test runs inside the repo so +# we can locate the repo root deterministically. +REPO_ROOT="$(cd "$SKILL_DIR" && cd ../../../../.. && pwd)" +if [ ! -x "$REPO_ROOT/bin/find-artifact.sh" ]; then + # Fallback: caller may have set NANOSTACK_ROOT explicitly. + REPO_ROOT="${NANOSTACK_ROOT:-$REPO_ROOT}" +fi +if [ ! -x "$REPO_ROOT/bin/find-artifact.sh" ]; then + echo "FAIL: cannot locate bin/find-artifact.sh from $SKILL_DIR" >&2 + exit 1 +fi + if [ ! -x "$SUMMARIZE" ]; then echo "FAIL: $SUMMARIZE is not executable" >&2 exit 1 fi - if ! command -v jq >/dev/null 2>&1; then echo "FAIL: jq is required for the smoke check" >&2 exit 1 fi -out=$( "$SUMMARIZE" 2>/dev/null ) -if ! printf '%s' "$out" | jq -e '.checks' >/dev/null 2>&1; then - echo "FAIL: summarize.sh did not emit a JSON object with .checks" >&2 - echo "$out" >&2 - exit 1 +tmp=$(mktemp -d /tmp/release-readiness-smoke.XXXXXX) +trap 'rm -rf "$tmp"' EXIT +fail=0 + +# Helper: write a phase artifact directly to disk so the smoke test +# does not depend on save-artifact.sh's project-scoping rules. The +# artifact includes a real .integrity hash computed the same way +# bin/save-artifact.sh computes it (sha256 of the canonical JSON +# form before adding the integrity field). release-readiness's +# verify path requires .integrity to be present, so writing a +# realistic hash here keeps the OK/WARN/BLOCKED cases honest. +write_artifact() { + local store="$1" phase="$2" status="$3" + local dir="$store/$phase" + mkdir -p "$dir" + local ts + ts=$(date -u +"%Y%m%d-%H%M%S") + local body + body=$(jq -n \ + --arg phase "$phase" \ + --arg status "$status" \ + --arg ts "$ts" \ + --arg project "$(pwd)" \ + '{ + phase: $phase, + status: "completed", + project: $project, + timestamp: ($ts | gsub("(?[0-9]{4})(?[0-9]{2})(?[0-9]{2})-(?[0-9]{2})(?[0-9]{2})(?[0-9]{2})"; "\(.a)-\(.b)-\(.c)T\(.d):\(.e):\(.f)Z")), + summary: { status: $status, headline: "smoke artifact" }, + context_checkpoint: { summary: "smoke" } + }') + local hash + hash=$(printf '%s' "$body" | jq -Sc '.' | shasum -a 256 | cut -d' ' -f1) + printf '%s' "$body" | jq --arg cs "$hash" '. + {integrity: $cs}' > "$dir/$ts.json" +} + +run_case() { + local label="$1" expected_rollup="$2"; shift 2 + # Remaining args: phase=status pairs. + local proj="$tmp/$label" + mkdir -p "$proj" + cd "$proj" + git init -q 2>/dev/null || true + local store="$proj/.nanostack" + mkdir -p "$store" + for pair in "$@"; do + local phase="${pair%%=*}" + local status="${pair#*=}" + write_artifact "$store" "$phase" "$status" + done + local out + out=$( + NANOSTACK_ROOT="$REPO_ROOT" \ + NANOSTACK_STORE="$store" \ + "$SUMMARIZE" 2>&1 + ) + cd "$tmp" + local got + got=$( echo "$out" | jq -r '.rollup_status' 2>/dev/null ) + if [ "$got" = "$expected_rollup" ]; then + echo " ok $label: rollup is $expected_rollup" + else + echo "FAIL: $label expected rollup $expected_rollup, got '$got'" + echo "$out" + fail=1 + fi +} + +# Case 1: all OK +run_case "all-ok" "OK" \ + review=OK qa=OK security=OK license-audit=OK privacy-check=OK + +# Case 2: one WARN +run_case "one-warn" "WARN" \ + review=OK qa=OK security=OK license-audit=OK privacy-check=WARN + +# Case 3: one BLOCKED +run_case "one-blocked" "BLOCKED" \ + review=OK qa=OK security=BLOCKED license-audit=OK privacy-check=OK + +# Case 4: one MISSING (qa absent) +run_case "qa-missing" "BLOCKED" \ + review=OK security=OK license-audit=OK privacy-check=OK + +# Case 5: WARN + MISSING -> still BLOCKED +run_case "mixed-warn-missing" "BLOCKED" \ + review=OK qa=WARN security=OK privacy-check=OK +# (license-audit deliberately omitted to simulate MISSING) + +# Case 6: tampered artifact -> rollup BLOCKED, per-check TAMPERED. +# A release gate must not treat a modified-after-save artifact as +# clean evidence. The smoke writes a security artifact with an +# explicit (wrong) integrity hash so find-artifact.sh --verify fails. +proj="$tmp/tampered" +mkdir -p "$proj" +cd "$proj" +git init -q 2>/dev/null || true +store="$proj/.nanostack" +mkdir -p "$store" +write_artifact "$store" review OK +write_artifact "$store" qa OK +write_artifact "$store" license-audit OK +write_artifact "$store" "privacy-check" OK +sec_dir="$store/security" +mkdir -p "$sec_dir" +ts=$(date -u +"%Y%m%d-%H%M%S") +jq -n --arg phase "security" --arg ts "$ts" --arg project "$proj" ' + { + phase: $phase, + status: "completed", + project: $project, + timestamp: ($ts | gsub("(?[0-9]{4})(?[0-9]{2})(?[0-9]{2})-(?[0-9]{2})(?[0-9]{2})(?[0-9]{2})"; "\(.a)-\(.b)-\(.c)T\(.d):\(.e):\(.f)Z")), + summary: { status: "OK", headline: "tampered case" }, + context_checkpoint: { summary: "smoke" }, + integrity: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + }' > "$sec_dir/$ts.json" +out=$( + NANOSTACK_ROOT="$REPO_ROOT" \ + NANOSTACK_STORE="$store" \ + "$SUMMARIZE" 2>&1 +) +cd "$tmp" +got=$( echo "$out" | jq -r '.rollup_status' 2>/dev/null ) +sec_status=$( echo "$out" | jq -r '.checks[] | select(.phase == "security") | .status' 2>/dev/null ) +if [ "$got" = "BLOCKED" ] && [ "$sec_status" = "TAMPERED" ]; then + echo " ok tampered: per-check is TAMPERED, rollup is BLOCKED" +else + echo "FAIL: tampered case wrong (rollup=$got, security=$sec_status)" + echo "$out" + fail=1 +fi + +# Case 7: artifact present but .integrity field stripped. An attacker +# who can write the file can delete the field as easily as mutate the +# hash. find-artifact.sh --verify silently accepts a missing +# .integrity (it only fails on hash MISMATCH). The release gate must +# reject unverifiable evidence: per-check TAMPERED with +# evidence=missing_integrity, rollup BLOCKED. +proj="$tmp/missing-integrity" +mkdir -p "$proj" +cd "$proj" +git init -q 2>/dev/null || true +store="$proj/.nanostack" +mkdir -p "$store" +write_artifact "$store" review OK +write_artifact "$store" qa OK +write_artifact "$store" license-audit OK +write_artifact "$store" "privacy-check" OK +write_artifact "$store" security OK +sec_path=$(ls "$store/security"/*.json 2>/dev/null | head -1) +jq 'del(.integrity)' "$sec_path" > "$sec_path.tmp" && mv "$sec_path.tmp" "$sec_path" +out=$( + NANOSTACK_ROOT="$REPO_ROOT" \ + NANOSTACK_STORE="$store" \ + "$SUMMARIZE" 2>&1 +) +cd "$tmp" +got=$( echo "$out" | jq -r '.rollup_status' 2>/dev/null ) +sec_status=$( echo "$out" | jq -r '.checks[] | select(.phase == "security") | .status' 2>/dev/null ) +sec_evidence=$( echo "$out" | jq -r '.checks[] | select(.phase == "security") | .evidence' 2>/dev/null ) +if [ "$got" = "BLOCKED" ] && [ "$sec_status" = "TAMPERED" ] && [ "$sec_evidence" = "missing_integrity" ]; then + echo " ok missing-integrity: per-check TAMPERED with evidence=missing_integrity, rollup BLOCKED" +else + echo "FAIL: missing-integrity case wrong (rollup=$got, security=$sec_status, evidence=$sec_evidence)" + echo "$out" + fail=1 fi -echo "OK: release-readiness placeholder smoke passed (PR 2 wires the real behavior)" +if [ "$fail" -eq 0 ]; then + echo "OK: release-readiness smoke passed (7 cases)" +fi +exit $fail diff --git a/examples/custom-stack-template/compliance-release/skills/release-readiness/bin/summarize.sh b/examples/custom-stack-template/compliance-release/skills/release-readiness/bin/summarize.sh index 1fe976e..70a44b7 100755 --- a/examples/custom-stack-template/compliance-release/skills/release-readiness/bin/summarize.sh +++ b/examples/custom-stack-template/compliance-release/skills/release-readiness/bin/summarize.sh @@ -1,16 +1,126 @@ #!/usr/bin/env bash -# summarize.sh — release-readiness skill (placeholder for PR 1 of CSE v1). +# summarize.sh — release-readiness skill helper. # -# PR 2 replaces this body with the real composer that walks the five -# upstream artifacts (review, qa, security, license-audit, -# privacy-check) and rolls them up into a status. PR 1 ships only -# the scaffolding so the static contract validates. -set -e - -cat <<'JSON' -{ - "checks": [], - "rollup_status": "OK", - "_placeholder": "release-readiness/bin/summarize.sh — PR 1 stub. Real behavior lands in PR 2." -} -JSON +# Composes upstream evidence from review, qa, security, license-audit, +# and privacy-check into a single rolled-up status. Reads the latest +# artifact for each upstream phase via bin/find-artifact.sh and maps +# each artifact's summary to a per-check status. +# +# Per-upstream status: +# - artifact missing -> MISSING +# - artifact present but integrity hash mismatch -> TAMPERED +# - artifact present but .integrity field absent -> TAMPERED +# (a release gate rejects unverifiable evidence; an attacker who +# can write the file can remove the integrity field as easily as +# mutate it, so missing integrity is the same risk class as a +# bad hash) +# - artifact present + verified, status=OK -> OK +# - artifact present + verified, status=WARN -> WARN +# - artifact present + verified, status=BLOCKED -> BLOCKED +# - artifact present + verified, status absent or +# unrecognized -> WARN +# +# Each lookup goes through find-artifact.sh --verify so a tampered +# artifact (mtime untouched, content rewritten) cannot quietly roll +# the gate up to OK. The tampered case is recorded as TAMPERED in +# the per-check entry and forces the rollup to BLOCKED, separately +# from "artifact never saved" which records as MISSING. +# +# save-artifact.sh always writes the .integrity field, so a +# legitimate artifact never trips the missing-integrity check. +# +# Rollup (monotonic, worst case wins): +# - any check is BLOCKED, TAMPERED, or MISSING -> BLOCKED +# - else any check is WARN -> WARN +# - else -> OK +# +# Output: JSON object with `checks` array and `rollup_status`. The +# calling skill saves the artifact and surfaces a headline + next +# action based on the rollup. +# +# Read-only. Does not run /ship, open PRs, commit, or deploy. +set -eu + +# Resolve nanostack root via env-var fallback so the snippet copy-pastes +# from the SKILL.md instructions. find-artifact.sh lives under bin/. +NANOSTACK_ROOT="${NANOSTACK_ROOT:-$HOME/.claude/skills/nanostack}" +FIND_ARTIFACT="$NANOSTACK_ROOT/bin/find-artifact.sh" + +if [ ! -x "$FIND_ARTIFACT" ]; then + echo "ERROR: $FIND_ARTIFACT not found or not executable" >&2 + echo " Set NANOSTACK_ROOT to your Nanostack checkout." >&2 + exit 2 +fi + +UPSTREAMS="review qa security license-audit privacy-check" + +CHECKS_JSON='[]' +ROLLUP="OK" +HAS_FAILURE=false # any of BLOCKED, TAMPERED, MISSING +HAS_WARN=false + +for phase in $UPSTREAMS; do + # First: does an artifact exist at all (any artifact, regardless of + # integrity)? Used to distinguish "never saved" from "saved but + # tampered with". + raw=$( "$FIND_ARTIFACT" "$phase" 30 2>/dev/null || true ) + if [ -z "$raw" ] || [ ! -f "$raw" ]; then + status="MISSING" + evidence="" + else + # Then: does it pass integrity verification? --verify exits 1 and + # prints "INTEGRITY FAILED" to stderr when the stored hash does + # not match the recomputed hash. A release gate must surface that + # explicitly, not treat a tampered file as clean evidence. + verified=$( "$FIND_ARTIFACT" "$phase" 30 --verify 2>/dev/null || true ) + if [ -z "$verified" ] || [ ! -f "$verified" ]; then + status="TAMPERED" + evidence="integrity_failure" + else + # find-artifact.sh --verify silently accepts artifacts whose + # .integrity field is missing — it only fails on a hash + # MISMATCH, not on absence. A release gate cannot afford that: + # an attacker who can write the file can delete the field as + # easily as mutate the hash. Require .integrity to be present. + stored_integrity=$( jq -r '.integrity // ""' "$verified" 2>/dev/null ) + if [ -z "$stored_integrity" ]; then + status="TAMPERED" + evidence="missing_integrity" + else + raw_status=$( jq -r '.summary.status // ""' "$verified" 2>/dev/null ) + case "$raw_status" in + OK|WARN|BLOCKED) status="$raw_status" ;; + "") status="WARN" ;; + *) status="WARN" ;; + esac + evidence="artifact" + fi + fi + fi + + case "$status" in + BLOCKED|TAMPERED|MISSING) HAS_FAILURE=true ;; + WARN) HAS_WARN=true ;; + esac + + CHECKS_JSON=$( echo "$CHECKS_JSON" | jq \ + --arg phase "$phase" \ + --arg status "$status" \ + --arg evidence "$evidence" \ + '. + [{phase: $phase, status: $status, evidence: ($evidence | select(. != "") // null)}]' ) +done + +# Monotonic worst-case rollup. BLOCKED dominates everything. +if [ "$HAS_FAILURE" = "true" ]; then + ROLLUP="BLOCKED" +elif [ "$HAS_WARN" = "true" ]; then + ROLLUP="WARN" +fi + +jq -n \ + --argjson checks "$CHECKS_JSON" \ + --arg rollup_status "$ROLLUP" \ + '{ + checks: $checks, + rollup_status: $rollup_status + }'