bigpowers 2.79.0 → 2.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.pi/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.79.0",
3
+ "version": "2.80.0",
4
4
  "description": "80 skills — 73 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "keywords": [
6
6
  "pi-package"
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [2.80.0](https://github.com/danielvm-git/bigpowers/compare/v2.79.1...v2.80.0) (2026-07-23)
2
+
3
+
4
+ ### Features
5
+
6
+ * **e54s01:** freeze skill catalog baseline snapshot ([0df0fff](https://github.com/danielvm-git/bigpowers/commit/0df0fff86fc48794a2343ae4b2c421835bc5c58c))
7
+
8
+ ## [2.79.1](https://github.com/danielvm-git/bigpowers/compare/v2.79.0...v2.79.1) (2026-07-23)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **e53:** close migration-readiness gaps — code-injection, expiry semantics, fail-open gate ([4042c62](https://github.com/danielvm-git/bigpowers/commit/4042c62f5ccac9790596f81aac5aaebd1235c763))
14
+
1
15
  # [2.79.0](https://github.com/danielvm-git/bigpowers/compare/v2.78.1...v2.79.0) (2026-07-23)
2
16
 
3
17
 
package/CONVENTIONS.md CHANGED
@@ -364,11 +364,15 @@ referencing the old name don't break immediately.
364
364
  renaming or merging a skill. It replaces `skills/<old-name>/SKILL.md` with a stub
365
365
  pointing at the new name/location, preserves the old skill's `# story: eNNsNN` tags
366
366
  for traceability, and registers the mapping in `specs/tombstones.yaml` (old name,
367
- new name/target, creation timestamp, and the `bigpowers_version` at creation time).
367
+ new name/target, creation timestamp, and the `created_at_version` at creation time).
368
368
  2. Run `bash scripts/validate-tombstones.sh` to confirm every registered tombstone's
369
- stub still resolves, and to flag any tombstone whose `created_at_version` differs
370
- from the current release — that stub has served its one-release transition window
371
- and should be removed.
369
+ stub still resolves, and to flag any tombstone whose `created_at_version` is in a
370
+ different minor/major release train (major.minor) than the current version — that
371
+ stub has served its one-release transition window and should be removed. Patch-only
372
+ bumps within the same minor version don't count as a new release: this repo ships
373
+ via semantic-release on every merge to main, so comparing full semver strings would
374
+ expire a tombstone within minutes of creation instead of giving consumers a real
375
+ migration window.
372
376
  3. **Required alongside the first real use of `tombstone-skill.sh`:** update
373
377
  `docs/references/model-profiles.md`'s skill-count annotations. A tombstone stub is
374
378
  still a `skills/*/SKILL.md` file — it increments the live skill count the same as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.79.0",
3
+ "version": "2.80.0",
4
4
  "description": "73 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -33,12 +33,25 @@ if [[ -f specs/traceability-matrix.json ]]; then
33
33
  # not-yet-built siblings (which have no code tags by definition until their
34
34
  # own turn) and not unrelated epics (which may be plan-worked-to-build-ready
35
35
  # but not yet kicked off).
36
- ACTIVE_STORY="$(grep '^active_story:' specs/state.yaml 2>/dev/null | awk '{print $2}')"
37
- if [[ -n "$ACTIVE_STORY" ]]; then
38
- UNDONE="$(jq --arg s "$ACTIVE_STORY" \
39
- '[.stories[]? | select(.id == $s and .status != "done" and ((.links // []) | map(select(.method == "explicit_tag")) | length) == 0)] | length' \
36
+ # `|| true` guards the whole pipe under `set -o pipefail`: a state.yaml
37
+ # with no active_story line makes grep exit 1, which would otherwise kill
38
+ # this script outright (no WARNING, no summary line) instead of falling
39
+ # through to the empty-ACTIVE_STORY branch below.
40
+ ACTIVE_STORY="$(grep '^active_story:' specs/state.yaml 2>/dev/null | awk '{print $2}' || true)"
41
+ if [[ -z "$ACTIVE_STORY" ]]; then
42
+ classify WARNING "specs/state.yaml has no active_story set — zero-tag check skipped, not a pass"
43
+ else
44
+ STORY_EXISTS="$(jq --arg s "$ACTIVE_STORY" \
45
+ '[.stories[]? | select(.id == $s)] | length' \
40
46
  specs/traceability-matrix.json 2>/dev/null || echo 0)"
41
- (( UNDONE > 0 )) && classify BLOCKER "active story $ACTIVE_STORY has zero explicit story-tag links"
47
+ if [[ "$STORY_EXISTS" -eq 0 ]]; then
48
+ classify WARNING "active_story $ACTIVE_STORY not found in specs/traceability-matrix.json — zero-tag check skipped, not a pass"
49
+ else
50
+ UNDONE="$(jq --arg s "$ACTIVE_STORY" \
51
+ '[.stories[]? | select(.id == $s and .status != "done" and ((.links // []) | map(select(.method == "explicit_tag")) | length) == 0)] | length' \
52
+ specs/traceability-matrix.json 2>/dev/null || echo 0)"
53
+ (( UNDONE > 0 )) && classify BLOCKER "active story $ACTIVE_STORY has zero explicit story-tag links"
54
+ fi
42
55
  fi
43
56
  COVERAGE="$(jq -r '.summary.coverage_percent // .coverage_percent // empty' specs/traceability-matrix.json 2>/dev/null)"
44
57
  if [[ -n "$COVERAGE" && "$COVERAGE" != "null" ]]; then
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env bash
2
+ # story: e54s01
3
+ # snapshot-catalog-baseline.sh — freeze the live skill catalog into a dated,
4
+ # immutable YAML baseline for e56's reclassification diff.
5
+ set -euo pipefail
6
+
7
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ source "$SCRIPT_DIR/lib/skill-common.sh"
9
+ source "$SCRIPT_DIR/lib/python-env.sh"
10
+
11
+ resolve_repo_root
12
+ cd "$REPO_ROOT"
13
+
14
+ DRY_RUN=false
15
+ while [[ $# -gt 0 ]]; do
16
+ case "$1" in
17
+ --dry-run) DRY_RUN=true; shift ;;
18
+ -h|--help) echo "Usage: bash scripts/snapshot-catalog-baseline.sh [--dry-run]"; exit 0 ;;
19
+ *) echo "Unknown flag: $1" >&2; exit 2 ;;
20
+ esac
21
+ done
22
+
23
+ DATE_STR="$(date +%Y-%m-%d)"
24
+ COMMIT_HASH="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
25
+ OUT_FILE="$REPO_ROOT/specs/tech-architecture/CATALOG-BASELINE-${DATE_STR}.yaml"
26
+
27
+ # unquote_source_desc — strip a source frontmatter's own surrounding quotes
28
+ # and unescape its inner \" , or re-quoting double-escapes the description.
29
+ unquote_source_desc() {
30
+ local desc="$1"
31
+ if [[ "$desc" == \"*\" ]]; then
32
+ desc="${desc#\"}"
33
+ desc="${desc%\"}"
34
+ desc="${desc//\\\"/\"}"
35
+ fi
36
+ echo "$desc"
37
+ }
38
+
39
+ # emit_skill_row — print one skill's YAML row to stdout; appends any missing-
40
+ # field warning line for that skill to $WARN_FILE (a plain file, since this
41
+ # function runs inside a command-substitution subshell and can't mutate the
42
+ # caller's shell variables).
43
+ emit_skill_row() {
44
+ local skill_md="$1" name phase model effort desc desc_escaped
45
+ name="$(basename "$(dirname "$skill_md")")"
46
+ parse_frontmatter_okf "$skill_md" || true
47
+
48
+ phase="$(awk '/^---/{f++} f==1 && /^phase:/{print; exit}' "$skill_md" | sed 's/^phase:[[:space:]]*//')"
49
+ model="${_PF_MODEL:-}"
50
+ effort="${_PF_EFFORT:-}"
51
+ desc="${_PF_DESCRIPTION:-}"
52
+
53
+ [[ -z "$phase" ]] && phase="null"
54
+ [[ -z "$model" ]] && { model="null"; echo " - \"${name}: missing model field\"" >> "$WARN_FILE"; }
55
+ [[ -z "$effort" ]] && { effort="null"; echo " - \"${name}: missing effort field\"" >> "$WARN_FILE"; }
56
+ [[ -z "$desc" ]] && desc="null"
57
+
58
+ desc="$(unquote_source_desc "$desc")"
59
+ desc_escaped="${desc//\"/\\\"}"
60
+
61
+ echo " - name: \"${name}\"\n path: \"skills/${name}/SKILL.md\"\n phase: ${phase}\n effort: ${effort}\n model: ${model}\n description: \"${desc_escaped}\"\n"
62
+ }
63
+
64
+ WARN_FILE="$(mktemp)"
65
+ trap 'rm -f "$WARN_FILE"' EXIT
66
+
67
+ ROWS=""
68
+ COUNT=0
69
+
70
+ for skill_md in "$SKILLS_ROOT"/*/SKILL.md; do
71
+ [[ -f "$skill_md" ]] || continue
72
+ ROWS="${ROWS}$(emit_skill_row "$skill_md")"
73
+ COUNT=$((COUNT + 1))
74
+ done
75
+
76
+ WARNINGS="$(cat "$WARN_FILE")"
77
+ WARN_COUNT="$(grep -c '^ -' "$WARN_FILE" || true)"
78
+
79
+ if $DRY_RUN; then
80
+ echo "${COUNT} skills scanned"
81
+ echo "${WARN_COUNT} warnings"
82
+ [[ -n "$WARNINGS" ]] && echo -e "warnings:\n${WARNINGS}"
83
+ exit 0
84
+ fi
85
+
86
+ mkdir -p "$REPO_ROOT/specs/tech-architecture"
87
+
88
+ {
89
+ echo "# CATALOG-BASELINE-${DATE_STR}.yaml"
90
+ echo "# frozen for e54-e59 migration; do not hand-edit"
91
+ echo "snapshot_date: \"${DATE_STR}\""
92
+ echo "commit_hash: \"${COMMIT_HASH}\""
93
+ echo "total_skill_count: ${COUNT}"
94
+ echo "warnings:"
95
+ if [[ -n "$WARNINGS" ]]; then
96
+ echo -e "$WARNINGS"
97
+ else
98
+ echo " []"
99
+ fi
100
+ echo "skills:"
101
+ echo -e "$ROWS"
102
+ } > "$OUT_FILE"
103
+
104
+ echo "Wrote ${OUT_FILE} (${COUNT} skills, ${WARN_COUNT} warnings)"
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env bash
2
+ # story: e53s01
3
+ # test-completeness-critic-scope.sh — regression test for
4
+ # BUG-2026-07-23-completeness-critic-code-tags-schema-drift: the zero-tag
5
+ # check must scope to the active story only (not siblings/other epics), and
6
+ # must WARN — not silently no-op — when active_story is unset or unresolved.
7
+ #
8
+ # Usage: bash scripts/test-completeness-critic-scope.sh
9
+
10
+ set -euo pipefail
11
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
12
+ cd "$REPO_ROOT"
13
+
14
+ RED='\033[0;31m'
15
+ GREEN='\033[0;32m'
16
+ NC='\033[0m'
17
+
18
+ FAILURES=0
19
+ tt_pass() { echo -e "${GREEN}PASS${NC} $1"; }
20
+ tt_fail() { echo -e "${RED}FAIL${NC} $1"; FAILURES=$((FAILURES + 1)); }
21
+
22
+ STATE_FILE="specs/state.yaml"
23
+ MATRIX_FILE="specs/traceability-matrix.json"
24
+ STATE_BACKUP=$(mktemp)
25
+ MATRIX_BACKUP=$(mktemp)
26
+ cp "$STATE_FILE" "$STATE_BACKUP"
27
+ cp "$MATRIX_FILE" "$MATRIX_BACKUP"
28
+
29
+ cleanup() {
30
+ cp "$STATE_BACKUP" "$STATE_FILE"
31
+ cp "$MATRIX_BACKUP" "$MATRIX_FILE"
32
+ rm -f "$STATE_BACKUP" "$MATRIX_BACKUP"
33
+ }
34
+ trap cleanup EXIT
35
+
36
+ run_critic() {
37
+ bash scripts/lib/completeness-critic.sh 2>&1 || true
38
+ }
39
+
40
+ # Fixture matrix: one untagged sibling story (e00s02) that must NOT block,
41
+ # and the active story (e00s01) which is under test.
42
+ write_matrix() {
43
+ local active_links="$1"
44
+ cat > "$MATRIX_FILE" <<FIXTUREEOF
45
+ {
46
+ "stories": [
47
+ {"id": "e00s01", "status": "in_progress", "links": ${active_links}},
48
+ {"id": "e00s02", "status": "todo", "links": []}
49
+ ]
50
+ }
51
+ FIXTUREEOF
52
+ }
53
+
54
+ # --- Scenario: active_story unset → WARNING, not silent pass ---
55
+ sed -i.bak '/^active_story:/d' "$STATE_FILE" && rm -f "${STATE_FILE}.bak"
56
+ write_matrix '[]'
57
+ output=$(run_critic)
58
+ if echo "$output" | grep -q "\[WARNING\].*no active_story set"; then
59
+ tt_pass "unset active_story produces an explicit WARNING"
60
+ else
61
+ tt_fail "unset active_story did not produce the expected WARNING"
62
+ fi
63
+
64
+ # --- Scenario: active_story set but absent from the matrix → WARNING ---
65
+ printf 'active_story: e99s99\n' >> "$STATE_FILE"
66
+ output=$(run_critic)
67
+ if echo "$output" | grep -q "\[WARNING\].*e99s99 not found"; then
68
+ tt_pass "unresolved active_story produces an explicit WARNING"
69
+ else
70
+ tt_fail "unresolved active_story did not produce the expected WARNING"
71
+ fi
72
+ sed -i.bak '/^active_story:/d' "$STATE_FILE" && rm -f "${STATE_FILE}.bak"
73
+
74
+ # --- Scenario: active story has zero explicit_tag links → BLOCKER, scoped to it ---
75
+ printf 'active_story: e00s01\n' >> "$STATE_FILE"
76
+ write_matrix '[]'
77
+ output=$(run_critic)
78
+ if echo "$output" | grep -q "\[BLOCKER\] active story e00s01 has zero explicit story-tag links"; then
79
+ tt_pass "active story with zero tags is correctly flagged as BLOCKER"
80
+ else
81
+ tt_fail "active story with zero tags was not flagged as BLOCKER"
82
+ fi
83
+
84
+ # --- Scenario: active story IS tagged → no BLOCKER, even though sibling e00s02 is untagged ---
85
+ write_matrix '[{"method": "explicit_tag", "file": "scripts/fixture.sh", "line": 1, "confidence": 1.0}]'
86
+ output=$(run_critic)
87
+ if echo "$output" | grep -q "\[BLOCKER\] active story e00s01"; then
88
+ tt_fail "tagged active story incorrectly flagged as BLOCKER"
89
+ else
90
+ tt_pass "tagged active story is not flagged, and untagged sibling e00s02 does not leak into the check"
91
+ fi
92
+
93
+ echo "---"
94
+ if [[ "$FAILURES" -eq 0 ]]; then
95
+ echo -e "${GREEN}test-completeness-critic-scope: ALL PASS${NC}"
96
+ else
97
+ echo -e "${RED}test-completeness-critic-scope: $FAILURES failure(s)${NC}"
98
+ fi
99
+ exit "$FAILURES"
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env bash
2
+ # story: e53s01
3
+ # test-generate-epics-wiki-zero-stories.sh — regression test for
4
+ # BUG-2026-07-23-epics-wiki-empty-references: generate-epics-wiki.sh must
5
+ # skip epics with zero sliced stories rather than emit an invalid OKF bundle
6
+ # with an empty references[].
7
+ #
8
+ # Usage: bash scripts/test-generate-epics-wiki-zero-stories.sh
9
+
10
+ set -euo pipefail
11
+ source "$(dirname "${BASH_SOURCE[0]}")/lib/skill-common.sh"
12
+ resolve_repo_root
13
+ cd "$REPO_ROOT"
14
+
15
+ RED='\033[0;31m'
16
+ GREEN='\033[0;32m'
17
+ NC='\033[0m'
18
+
19
+ FAILURES=0
20
+ tt_pass() { echo -e "${GREEN}PASS${NC} $1"; }
21
+ tt_fail() { echo -e "${RED}FAIL${NC} $1"; FAILURES=$((FAILURES + 1)); }
22
+
23
+ FIXTURE_ID="e00"
24
+ FIXTURE_DIR="specs/epics/e00-zero-story-fixture"
25
+ FIXTURE_BUNDLE="specs/epics-wiki/${FIXTURE_ID}.okf.md"
26
+
27
+ cleanup() {
28
+ rm -rf "$FIXTURE_DIR"
29
+ rm -f "$FIXTURE_BUNDLE"
30
+ }
31
+ trap cleanup EXIT
32
+
33
+ mkdir -p "$FIXTURE_DIR"
34
+ cat > "${FIXTURE_DIR}/epic.yaml" <<'FIXTUREEOF'
35
+ id: e00
36
+ title: "Zero-story fixture epic"
37
+ wsjf: 0
38
+ bcps: 0
39
+ capsule_dir: epics/e00-zero-story-fixture
40
+ mode: capsule
41
+ status: todo
42
+ depends_on: []
43
+ stories: []
44
+ FIXTUREEOF
45
+
46
+ # A scoped-but-unsliced epic has a `stories:` key with nothing under it —
47
+ # grep -c '^\s*- id:' correctly returns 0 for this shape.
48
+ [[ -f "$FIXTURE_BUNDLE" ]] && rm -f "$FIXTURE_BUNDLE"
49
+
50
+ bash scripts/generate-epics-wiki.sh > /dev/null
51
+
52
+ if [[ ! -f "$FIXTURE_BUNDLE" ]]; then
53
+ tt_pass "generate-epics-wiki.sh skips a zero-story epic (no bundle emitted)"
54
+ else
55
+ tt_fail "generate-epics-wiki.sh emitted a bundle for a zero-story epic: $FIXTURE_BUNDLE"
56
+ fi
57
+
58
+ echo "---"
59
+ if [[ "$FAILURES" -eq 0 ]]; then
60
+ echo -e "${GREEN}test-generate-epics-wiki-zero-stories: ALL PASS${NC}"
61
+ else
62
+ echo -e "${RED}test-generate-epics-wiki-zero-stories: $FAILURES failure(s)${NC}"
63
+ fi
64
+ exit "$FAILURES"
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env bash
2
+ # story: e53s01
3
+ # test-golden-g11-worktree-venv.sh — regression test for
4
+ # BUG-2026-07-23-golden-g11-worktree-venv: golden-g11-gitignore-venv.sh must
5
+ # PASS in a fresh worktree where .venv does not exist yet on disk (before
6
+ # setup-environment has run), not just when a real .venv is already present.
7
+ #
8
+ # Usage: bash scripts/test-golden-g11-worktree-venv.sh
9
+
10
+ set -euo pipefail
11
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
12
+ cd "$REPO_ROOT"
13
+
14
+ RED='\033[0;31m'
15
+ GREEN='\033[0;32m'
16
+ NC='\033[0m'
17
+
18
+ FAILURES=0
19
+ tt_pass() { echo -e "${GREEN}PASS${NC} $1"; }
20
+ tt_fail() { echo -e "${RED}FAIL${NC} $1"; FAILURES=$((FAILURES + 1)); }
21
+ strip_ansi() { sed -E 's/\x1b\[[0-9;]*m//g'; }
22
+
23
+ VENV_MOVED=0
24
+ if [[ -e .venv ]]; then
25
+ mv .venv .venv.test-golden-g11-backup
26
+ VENV_MOVED=1
27
+ fi
28
+
29
+ cleanup() {
30
+ rm -rf .venv
31
+ if [[ "$VENV_MOVED" -eq 1 ]]; then
32
+ mv .venv.test-golden-g11-backup .venv
33
+ fi
34
+ }
35
+ trap cleanup EXIT
36
+
37
+ # --- Scenario: .venv absent (fresh worktree) → gate still PASSes ---
38
+ if [[ -e .venv ]]; then
39
+ tt_fail "test setup failed to remove .venv before running the gate"
40
+ else
41
+ output=$(bash scripts/golden-g11-gitignore-venv.sh 2>&1 | strip_ansi) && exit_code=0 || exit_code=$?
42
+ if [[ "$exit_code" -eq 0 ]] && echo "$output" | grep -q "PASS git check-ignore .venv"; then
43
+ tt_pass "gate PASSes when .venv does not exist on disk"
44
+ else
45
+ tt_fail "gate did not PASS with .venv absent (exit=$exit_code)"
46
+ fi
47
+
48
+ # The gate must clean up the .venv it created for the check — a fresh
49
+ # worktree shouldn't be left with a stray directory after a gate run.
50
+ if [[ ! -e .venv ]]; then
51
+ tt_pass "gate removes the .venv it created for the check"
52
+ else
53
+ tt_fail "gate left a stray .venv behind after the check"
54
+ fi
55
+ fi
56
+
57
+ echo "---"
58
+ if [[ "$FAILURES" -eq 0 ]]; then
59
+ echo -e "${GREEN}test-golden-g11-worktree-venv: ALL PASS${NC}"
60
+ else
61
+ echo -e "${RED}test-golden-g11-worktree-venv: $FAILURES failure(s)${NC}"
62
+ fi
63
+ exit "$FAILURES"
@@ -102,6 +102,48 @@ else
102
102
  tt_fail "expired tombstone was not flagged"
103
103
  fi
104
104
 
105
+ # --- Scenario: rejects path-traversal / invalid skill names (threat model CWE-22) ---
106
+ # tombstone-skill.sh exits non-zero on rejection, and this script runs under
107
+ # `set -o pipefail` — capture output via `|| true` first so the pipe's exit
108
+ # status doesn't mask the grep check below.
109
+ output=$(bash scripts/tombstone-skill.sh "../../etc/passwd" "target" 2>&1 || true)
110
+ if echo "$output" | grep -qi "not a valid skill name"; then
111
+ tt_pass "tombstone-skill.sh rejects a path-traversal old-name"
112
+ else
113
+ tt_fail "tombstone-skill.sh did not reject a path-traversal old-name"
114
+ fi
115
+
116
+ output=$(bash scripts/tombstone-skill.sh "$FIXTURE_OLD" "../../etc/passwd" 2>&1 || true)
117
+ if echo "$output" | grep -qi "not a valid skill name"; then
118
+ tt_pass "tombstone-skill.sh rejects a path-traversal new-name"
119
+ else
120
+ tt_fail "tombstone-skill.sh did not reject a path-traversal new-name"
121
+ fi
122
+
123
+ # --- Scenario: validate-tombstones.sh rejects an invalid name read back from the ledger ---
124
+ python3 -c "
125
+ import yaml
126
+ d = yaml.safe_load(open('$TOMBSTONES_FILE')) or {'tombstones': []}
127
+ d.setdefault('tombstones', []).append({
128
+ 'old_name': '../../etc/passwd',
129
+ 'new_name_or_merge_target': 'target',
130
+ 'created_at_version': '0.0.0',
131
+ })
132
+ yaml.dump(d, open('$TOMBSTONES_FILE', 'w'), default_flow_style=False)
133
+ "
134
+ output=$(bash scripts/validate-tombstones.sh 2>&1 | strip_ansi || true)
135
+ if echo "$output" | grep -qi "FAIL.*not a valid skill name"; then
136
+ tt_pass "validate-tombstones.sh rejects a path-traversal name read from the ledger"
137
+ else
138
+ tt_fail "validate-tombstones.sh did not reject a path-traversal name read from the ledger"
139
+ fi
140
+ python3 -c "
141
+ import yaml
142
+ d = yaml.safe_load(open('$TOMBSTONES_FILE')) or {'tombstones': []}
143
+ d['tombstones'] = [t for t in d.get('tombstones', []) if t.get('old_name') != '../../etc/passwd']
144
+ yaml.dump(d, open('$TOMBSTONES_FILE', 'w'), default_flow_style=False)
145
+ "
146
+
105
147
  echo "---"
106
148
  if [[ "$FAILURES" -eq 0 ]]; then
107
149
  echo -e "${GREEN}test-tombstone-mechanism: ALL PASS${NC}"
@@ -51,7 +51,7 @@ if [[ ! -f "$OLD_SKILL_MD" ]]; then
51
51
  fi
52
52
 
53
53
  STORY_TAGS=$(grep -E '^#\s*story:' "$OLD_SKILL_MD" || true)
54
- CURRENT_VERSION=$(grep '"version"' package.json 2>/dev/null | head -1 | sed 's/.*: *"\(.*\)".*/\1/')
54
+ CURRENT_VERSION=$(jq -r '.version' package.json 2>/dev/null)
55
55
  CREATED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
56
56
 
57
57
  cat > "$OLD_SKILL_MD" <<STUBEOF
@@ -73,16 +73,27 @@ STUBEOF
73
73
  TOMBSTONES_FILE="specs/tombstones.yaml"
74
74
  [[ -f "$TOMBSTONES_FILE" ]] || echo "tombstones: []" > "$TOMBSTONES_FILE"
75
75
 
76
+ # Values flow through the environment, never interpolated into the python
77
+ # source string — string-interpolating shell variables into `python3 -c`
78
+ # turns any unvalidated field (e.g. CURRENT_VERSION, sourced from
79
+ # package.json) into a code-injection vector.
80
+ TOMBSTONE_FILE="$TOMBSTONES_FILE" \
81
+ TOMBSTONE_OLD_NAME="$OLD_NAME" \
82
+ TOMBSTONE_NEW_NAME="$NEW_NAME" \
83
+ TOMBSTONE_CREATED_AT="$CREATED_AT" \
84
+ TOMBSTONE_VERSION="$CURRENT_VERSION" \
76
85
  python3 -c "
86
+ import os
77
87
  import yaml
78
- d = yaml.safe_load(open('$TOMBSTONES_FILE')) or {'tombstones': []}
88
+ path = os.environ['TOMBSTONE_FILE']
89
+ d = yaml.safe_load(open(path)) or {'tombstones': []}
79
90
  d.setdefault('tombstones', []).append({
80
- 'old_name': '$OLD_NAME',
81
- 'new_name_or_merge_target': '$NEW_NAME',
82
- 'created_at': '$CREATED_AT',
83
- 'created_at_version': '$CURRENT_VERSION',
91
+ 'old_name': os.environ['TOMBSTONE_OLD_NAME'],
92
+ 'new_name_or_merge_target': os.environ['TOMBSTONE_NEW_NAME'],
93
+ 'created_at': os.environ['TOMBSTONE_CREATED_AT'],
94
+ 'created_at_version': os.environ['TOMBSTONE_VERSION'],
84
95
  })
85
- yaml.dump(d, open('$TOMBSTONES_FILE', 'w'), default_flow_style=False)
96
+ yaml.dump(d, open(path, 'w'), default_flow_style=False)
86
97
  "
87
98
 
88
99
  echo "Tombstoned: ${OLD_NAME} -> ${NEW_NAME}"
@@ -16,6 +16,7 @@ YELLOW='\033[1;33m'
16
16
  NC='\033[0m'
17
17
 
18
18
  TOMBSTONES_FILE="specs/tombstones.yaml"
19
+ NAME_PATTERN='^[a-z][a-z0-9-]*$'
19
20
  FAIL=0
20
21
 
21
22
  if [[ ! -f "$TOMBSTONES_FILE" ]]; then
@@ -23,7 +24,14 @@ if [[ ! -f "$TOMBSTONES_FILE" ]]; then
23
24
  exit 0
24
25
  fi
25
26
 
26
- CURRENT_VERSION=$(grep '"version"' package.json 2>/dev/null | head -1 | sed 's/.*: *"\(.*\)".*/\1/')
27
+ CURRENT_VERSION=$(jq -r '.version' package.json 2>/dev/null)
28
+
29
+ # A "release" for tombstone-expiry purposes is a minor/major version bump.
30
+ # This repo ships via semantic-release on every merge to main (multiple
31
+ # patch releases per day) — comparing full semver strings would expire
32
+ # every tombstone within minutes instead of giving consumers the one-release
33
+ # migration window CONVENTIONS.md promises.
34
+ release_train() { echo "$1" | cut -d. -f1-2; }
27
35
 
28
36
  ROWS=$(python3 -c "
29
37
  import yaml
@@ -39,6 +47,17 @@ fi
39
47
 
40
48
  while IFS=$'\t' read -r old_name new_name created_version; do
41
49
  [[ -z "$old_name" ]] && continue
50
+
51
+ # Threat-model mitigation (specs/security/epics/e53/THREAT_MODEL.md): apply
52
+ # the same kebab-case validation tombstone-skill.sh applies before writing
53
+ # entries, since these names come back out of specs/tombstones.yaml here
54
+ # and are used to construct a filesystem path.
55
+ if [[ ! "$old_name" =~ $NAME_PATTERN || ! "$new_name" =~ $NAME_PATTERN ]]; then
56
+ echo -e "${RED}FAIL${NC}: '${old_name}' -> '${new_name}' — not a valid skill name (must match ${NAME_PATTERN})"
57
+ FAIL=1
58
+ continue
59
+ fi
60
+
42
61
  stub="skills/${old_name}/SKILL.md"
43
62
 
44
63
  if [[ ! -f "$stub" ]]; then
@@ -52,7 +71,7 @@ while IFS=$'\t' read -r old_name new_name created_version; do
52
71
  continue
53
72
  fi
54
73
 
55
- if [[ -n "$created_version" && "$created_version" != "$CURRENT_VERSION" ]]; then
74
+ if [[ -n "$created_version" && "$(release_train "$created_version")" != "$(release_train "$CURRENT_VERSION")" ]]; then
56
75
  echo -e "${YELLOW}EXPIRED${NC}: ${old_name} -> ${new_name} — created at ${created_version}, now ${CURRENT_VERSION}; remove the stub"
57
76
  continue
58
77
  fi