mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7de4e943f5 | ||
|
|
d9e4565cf8 | ||
|
|
840fb8d786 | ||
|
|
0d2e3b5e76 | ||
|
|
41a8e07f4c | ||
|
|
01d07e2f87 | ||
|
|
956ecab230 | ||
|
|
30e99ec083 | ||
|
|
717d7c4b89 | ||
|
|
b79ae330fd | ||
|
|
b6b3ec49d9 | ||
|
|
48686521ff | ||
|
|
ebd3097eb3 | ||
|
|
115bc94cce | ||
|
|
d7699c39f2 | ||
|
|
e9d84ca4fb |
115
.github/scripts/check_security_requirements.py
vendored
Normal file
115
.github/scripts/check_security_requirements.py
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Check that committed security audit requirements are up to date."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
COMMITTED_REQUIREMENTS = REPO_ROOT / ".github" / "security-audit-requirements.txt"
|
||||
DEPENDENCY_INPUTS = ("pyproject.toml", ".github/security-audit-requirements.txt")
|
||||
|
||||
|
||||
def _dependency_diff_refs() -> tuple[str, str]:
|
||||
base_ref = os.environ.get("DEPENDENCY_DIFF_BASE", "").strip()
|
||||
head_ref = os.environ.get("DEPENDENCY_DIFF_HEAD", "").strip() or "HEAD"
|
||||
if base_ref and not set(base_ref) <= {"0"}:
|
||||
return base_ref, head_ref
|
||||
# Fallback when no usable base is supplied (push with an all-zero
|
||||
# ``github.event.before``, manual dispatch, etc.). ``HEAD^`` fails on a
|
||||
# shallow checkout or a single-commit repo; that ``git diff`` error is
|
||||
# caught by the caller and deliberately treated as "inputs changed" so the
|
||||
# audit runs anyway — failing safe (audit) rather than skipping silently.
|
||||
return "HEAD^", "HEAD"
|
||||
|
||||
|
||||
def _dependency_inputs_changed() -> bool:
|
||||
base_ref, head_ref = _dependency_diff_refs()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"diff",
|
||||
"--name-only",
|
||||
base_ref,
|
||||
head_ref,
|
||||
"--",
|
||||
*DEPENDENCY_INPUTS,
|
||||
],
|
||||
check=True,
|
||||
cwd=REPO_ROOT,
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(
|
||||
"Could not determine changed dependency inputs; checking requirements.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if exc.stderr:
|
||||
print(exc.stderr.strip(), file=sys.stderr)
|
||||
return True
|
||||
|
||||
changed_inputs = [line for line in result.stdout.splitlines() if line]
|
||||
if not changed_inputs:
|
||||
print("Dependency audit inputs unchanged; sync check skipped.")
|
||||
return False
|
||||
|
||||
print(f"Dependency audit inputs changed: {', '.join(changed_inputs)}")
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not _dependency_inputs_changed():
|
||||
return 0
|
||||
|
||||
generated_requirements_env = os.environ.get("GENERATED_REQUIREMENTS", "").strip()
|
||||
if not generated_requirements_env:
|
||||
print(
|
||||
"GENERATED_REQUIREMENTS must be set to the temporary output file path.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
generated_requirements = Path(generated_requirements_env)
|
||||
generated_requirements.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"uv",
|
||||
"pip",
|
||||
"compile",
|
||||
"pyproject.toml",
|
||||
"--extra",
|
||||
"test",
|
||||
"--universal",
|
||||
"--upgrade",
|
||||
"--generate-hashes",
|
||||
"--quiet",
|
||||
"--no-header",
|
||||
"--output-file",
|
||||
str(generated_requirements),
|
||||
],
|
||||
check=True,
|
||||
cwd=REPO_ROOT,
|
||||
)
|
||||
|
||||
committed = COMMITTED_REQUIREMENTS.read_text(encoding="utf-8")
|
||||
generated = generated_requirements.read_text(encoding="utf-8")
|
||||
if committed == generated:
|
||||
return 0
|
||||
|
||||
print(
|
||||
"Regenerate .github/security-audit-requirements.txt with the documented "
|
||||
"uv pip compile command.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
253
.github/security-audit-requirements.txt
vendored
Normal file
253
.github/security-audit-requirements.txt
vendored
Normal file
@@ -0,0 +1,253 @@
|
||||
annotated-doc==0.0.4 \
|
||||
--hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \
|
||||
--hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4
|
||||
# via typer
|
||||
click==8.4.2 \
|
||||
--hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \
|
||||
--hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
|
||||
# via specify-cli (pyproject.toml)
|
||||
colorama==0.4.6 ; sys_platform == 'win32' \
|
||||
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
|
||||
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
|
||||
# via
|
||||
# click
|
||||
# pytest
|
||||
# typer
|
||||
coverage==7.15.2 \
|
||||
--hash=sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2 \
|
||||
--hash=sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e \
|
||||
--hash=sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db \
|
||||
--hash=sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf \
|
||||
--hash=sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c \
|
||||
--hash=sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8 \
|
||||
--hash=sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443 \
|
||||
--hash=sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a \
|
||||
--hash=sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145 \
|
||||
--hash=sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9 \
|
||||
--hash=sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2 \
|
||||
--hash=sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376 \
|
||||
--hash=sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138 \
|
||||
--hash=sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578 \
|
||||
--hash=sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c \
|
||||
--hash=sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88 \
|
||||
--hash=sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036 \
|
||||
--hash=sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c \
|
||||
--hash=sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071 \
|
||||
--hash=sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a \
|
||||
--hash=sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d \
|
||||
--hash=sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b \
|
||||
--hash=sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a \
|
||||
--hash=sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b \
|
||||
--hash=sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050 \
|
||||
--hash=sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846 \
|
||||
--hash=sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d \
|
||||
--hash=sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1 \
|
||||
--hash=sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660 \
|
||||
--hash=sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40 \
|
||||
--hash=sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026 \
|
||||
--hash=sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0 \
|
||||
--hash=sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b \
|
||||
--hash=sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0 \
|
||||
--hash=sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa \
|
||||
--hash=sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d \
|
||||
--hash=sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658 \
|
||||
--hash=sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89 \
|
||||
--hash=sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072 \
|
||||
--hash=sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199 \
|
||||
--hash=sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446 \
|
||||
--hash=sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743 \
|
||||
--hash=sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7 \
|
||||
--hash=sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1 \
|
||||
--hash=sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287 \
|
||||
--hash=sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5 \
|
||||
--hash=sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be \
|
||||
--hash=sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688 \
|
||||
--hash=sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934 \
|
||||
--hash=sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7 \
|
||||
--hash=sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440 \
|
||||
--hash=sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984 \
|
||||
--hash=sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc \
|
||||
--hash=sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d \
|
||||
--hash=sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098 \
|
||||
--hash=sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6 \
|
||||
--hash=sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629 \
|
||||
--hash=sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b \
|
||||
--hash=sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee \
|
||||
--hash=sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f \
|
||||
--hash=sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1 \
|
||||
--hash=sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad \
|
||||
--hash=sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3 \
|
||||
--hash=sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9 \
|
||||
--hash=sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3 \
|
||||
--hash=sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a \
|
||||
--hash=sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296 \
|
||||
--hash=sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1 \
|
||||
--hash=sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd \
|
||||
--hash=sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73 \
|
||||
--hash=sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f \
|
||||
--hash=sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0 \
|
||||
--hash=sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6 \
|
||||
--hash=sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5 \
|
||||
--hash=sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1 \
|
||||
--hash=sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589 \
|
||||
--hash=sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688 \
|
||||
--hash=sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487 \
|
||||
--hash=sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9 \
|
||||
--hash=sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd \
|
||||
--hash=sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee \
|
||||
--hash=sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d \
|
||||
--hash=sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d \
|
||||
--hash=sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb \
|
||||
--hash=sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a \
|
||||
--hash=sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328 \
|
||||
--hash=sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635 \
|
||||
--hash=sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188 \
|
||||
--hash=sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c \
|
||||
--hash=sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243 \
|
||||
--hash=sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a
|
||||
# via pytest-cov
|
||||
iniconfig==2.3.0 \
|
||||
--hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
|
||||
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
|
||||
# via pytest
|
||||
json5==0.15.0 \
|
||||
--hash=sha256:56636a30c0e8a4665fe2179c0212f32eae3796dea89ea6f649b9436ecdb39618 \
|
||||
--hash=sha256:7424d1f1eb1d56da6e3d70643f53619862b4ce81440bdb8ecfd6f875e5ba4a71
|
||||
# via specify-cli (pyproject.toml)
|
||||
markdown-it-py==4.2.0 \
|
||||
--hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \
|
||||
--hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a
|
||||
# via rich
|
||||
mdurl==0.1.2 \
|
||||
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
|
||||
--hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
|
||||
# via markdown-it-py
|
||||
packaging==26.2 \
|
||||
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
|
||||
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
|
||||
# via
|
||||
# specify-cli (pyproject.toml)
|
||||
# pytest
|
||||
pathspec==1.1.1 \
|
||||
--hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \
|
||||
--hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189
|
||||
# via specify-cli (pyproject.toml)
|
||||
platformdirs==4.11.0 \
|
||||
--hash=sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0 \
|
||||
--hash=sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74
|
||||
# via specify-cli (pyproject.toml)
|
||||
pluggy==1.6.0 \
|
||||
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
|
||||
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
|
||||
# via
|
||||
# pytest
|
||||
# pytest-cov
|
||||
pygments==2.20.0 \
|
||||
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
|
||||
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
|
||||
# via
|
||||
# pytest
|
||||
# rich
|
||||
pytest==9.1.1 \
|
||||
--hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \
|
||||
--hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
|
||||
# via
|
||||
# specify-cli (pyproject.toml)
|
||||
# pytest-cov
|
||||
pytest-cov==7.1.0 \
|
||||
--hash=sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2 \
|
||||
--hash=sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678
|
||||
# via specify-cli (pyproject.toml)
|
||||
pyyaml==6.0.3 \
|
||||
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
|
||||
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
|
||||
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
|
||||
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
|
||||
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
|
||||
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
|
||||
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
|
||||
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
|
||||
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
|
||||
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
|
||||
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
|
||||
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
|
||||
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
|
||||
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
|
||||
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
|
||||
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
|
||||
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
|
||||
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
|
||||
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
|
||||
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
|
||||
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
|
||||
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
|
||||
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
|
||||
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
|
||||
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
|
||||
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
|
||||
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
|
||||
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
|
||||
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
|
||||
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
|
||||
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
|
||||
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
|
||||
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
|
||||
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
|
||||
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
|
||||
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
|
||||
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
|
||||
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
|
||||
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
|
||||
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
|
||||
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
|
||||
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
|
||||
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
|
||||
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
|
||||
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
|
||||
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
|
||||
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
|
||||
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
|
||||
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
|
||||
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
|
||||
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
|
||||
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
|
||||
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
|
||||
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
|
||||
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
|
||||
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
|
||||
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
|
||||
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
|
||||
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
|
||||
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
|
||||
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
|
||||
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
|
||||
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
|
||||
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
|
||||
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
|
||||
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
|
||||
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
|
||||
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
|
||||
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
|
||||
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
|
||||
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
|
||||
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
|
||||
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
|
||||
# via specify-cli (pyproject.toml)
|
||||
readchar==4.2.2 \
|
||||
--hash=sha256:92daf7e42c52b0787e6c75d01ecfb9a94f4ceff3764958b570c1dddedd47b200 \
|
||||
--hash=sha256:e3b270fe16fc90c50ac79107700330a133dd4c63d22939f5b03b4f24564d5dd8
|
||||
# via specify-cli (pyproject.toml)
|
||||
rich==15.0.0 \
|
||||
--hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \
|
||||
--hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36
|
||||
# via
|
||||
# specify-cli (pyproject.toml)
|
||||
# typer
|
||||
shellingham==1.5.4 \
|
||||
--hash=sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 \
|
||||
--hash=sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de
|
||||
# via typer
|
||||
typer==0.27.0 \
|
||||
--hash=sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5 \
|
||||
--hash=sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1
|
||||
# via specify-cli (pyproject.toml)
|
||||
78
.github/workflows/security.yml
vendored
Normal file
78
.github/workflows/security.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
name: Security Audit
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
schedule:
|
||||
- cron: "17 4 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
dependency-audit:
|
||||
name: Dependency audit
|
||||
if: ${{ github.event_name != 'schedule' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Check committed audit requirements are current
|
||||
env:
|
||||
DEPENDENCY_DIFF_BASE: ${{ github.event.pull_request.base.sha || github.event.before || '' }}
|
||||
DEPENDENCY_DIFF_HEAD: ${{ github.sha }}
|
||||
GENERATED_REQUIREMENTS: ${{ runner.temp }}/security-audit-requirements.txt
|
||||
run: python .github/scripts/check_security_requirements.py
|
||||
|
||||
- name: Run pip-audit (committed requirements)
|
||||
run: uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r .github/security-audit-requirements.txt --progress-spinner off
|
||||
|
||||
dependency-audit-scheduled:
|
||||
name: Dependency audit scheduled (${{ matrix.os }}, Python ${{ matrix.python-version }})
|
||||
if: ${{ github.event_name == 'schedule' }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
# The committed .github/security-audit-requirements.txt is generated with
|
||||
# --universal (resolves across all interpreters/platforms) and is what
|
||||
# push/PR/workflow_dispatch runs audit. The scheduled job instead compiles
|
||||
# per matrix entry with --python-version so it can surface advisories in
|
||||
# wheels that only resolve on a specific interpreter (e.g. 3.11-only) —
|
||||
# coverage the universal file may not exercise. This broadening is
|
||||
# intentional; non-scheduled runs trade that depth for determinism against
|
||||
# the committed snapshot.
|
||||
- name: Compile scheduled audit requirements
|
||||
run: |
|
||||
uv pip compile pyproject.toml --extra test --python-version "${{ matrix.python-version }}" --upgrade --generate-hashes --quiet --output-file "${{ runner.temp }}/spec-kit-audit-requirements.txt"
|
||||
|
||||
- name: Run pip-audit (scheduled live resolution)
|
||||
run: uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r "${{ runner.temp }}/spec-kit-audit-requirements.txt" --progress-spinner off
|
||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -2,6 +2,46 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.13.3] - 2026-07-22
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(integrations): escape Rich markup in --integration-options error messages (#3458)
|
||||
- docs: document __SPECKIT_COMMAND_ token for portable cross-command references (#3503)
|
||||
- [preset] Add Parallel Autonomous Run Governance preset to community catalog (#3614)
|
||||
- docs(workflows): fix stale FanOutStep docstring claiming sequential-only execution (#3639)
|
||||
- [bundle] Add SicarioSpec Security & Governance Bundle to community catalog (#3636)
|
||||
- [preset] Update Autonomous Run Governance preset to v0.3.2 (#3615)
|
||||
- fix(workflows): validate every redirect hop when fetching workflow/step catalogs (#3637)
|
||||
- Add pipeline workflow to community catalog (#3338)
|
||||
- [extension] Add Linear Weave extension to community catalog (#3609)
|
||||
- docs: clarify hook priority validation semantics (#3594)
|
||||
- fix(workflows): reject a non-string 'integration'/'model' in command & prompt steps (#3597)
|
||||
- ci: add dependency audit workflow (#3138)
|
||||
- Add Intake Review Governance preset to community catalog (#3613)
|
||||
- fix(workflows): reject non-list input 'enum' instead of crashing (#3601)
|
||||
- chore: release 0.13.2, begin 0.13.3.dev0 development (#3617)
|
||||
|
||||
## [0.13.2] - 2026-07-21
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(workflows): reject a non-string 'command' in command-step (#3596)
|
||||
- fix(workflows): fail gate step loudly on a malformed 'options' (#3595)
|
||||
- fix(extensions): re-validate catalog URL after redirects (HTTPS parity/security) (#3524)
|
||||
- Add community bundle submission automation (#3553)
|
||||
- fix(presets): re-validate catalog URL after redirects (HTTPS parity/security) (#3523)
|
||||
- feat(scripts): port create-new-feature, setup-plan and setup-tasks to Python (#3386)
|
||||
- fix(agents): parse frontmatter on the --- delimiter line, not any --- substring (#3590)
|
||||
- [bug-fix] Fix reinstall-overwrites-kept-config: preserve config on plain reinstall after --keep-config (#3449)
|
||||
- feat: update Bob integration to skills-based layout for Bob 2.0 (#3415)
|
||||
- Update OKF Knowledge Bundle Generator to v0.3.0 (#3608)
|
||||
- Add Test Coverage Drift Control extension to community catalog (#3607)
|
||||
- chore: align ruff lint scope (#3139)
|
||||
- feat(workflows): WorkflowResolver standalone (PR 1) (#3557)
|
||||
- fix(extensions,presets): surface clean error on malformed download URL (#3577)
|
||||
- chore: release 0.13.1, begin 0.13.2.dev0 development (#3610)
|
||||
|
||||
## [0.13.1] - 2026-07-21
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -113,6 +113,27 @@ uv pip install -e ".[test]"
|
||||
> `specify_cli` to this checkout's `src/`. This matches the gotcha documented in
|
||||
> `AGENTS.md` (Common Pitfalls).
|
||||
|
||||
#### Security checks
|
||||
|
||||
```bash
|
||||
uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r .github/security-audit-requirements.txt --progress-spinner off
|
||||
```
|
||||
|
||||
This command audits the committed hashed requirements snapshot. Pull request,
|
||||
push, and manual CI runs use the same snapshot so their results stay
|
||||
deterministic. If dependency metadata changes, refresh and commit the snapshot
|
||||
before auditing it:
|
||||
|
||||
```bash
|
||||
uv pip compile pyproject.toml --extra test --universal --upgrade --generate-hashes --quiet --no-header --output-file .github/security-audit-requirements.txt
|
||||
```
|
||||
|
||||
The scheduled CI audit resolves the runtime and `test` extra dependency set
|
||||
across the supported Python and OS matrix to catch newly published advisories.
|
||||
Upstream package releases drift over time, so even an unrelated PR touching
|
||||
`pyproject.toml` can fail the `dependency-audit` check until the committed file
|
||||
is regenerated with the command above and re-committed.
|
||||
|
||||
#### Shell scripts
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-15T00:00:00Z",
|
||||
"updated_at": "2026-07-22T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/bundles/catalog.community.json",
|
||||
"bundles": {}
|
||||
"bundles": {
|
||||
"sicario-spec": {
|
||||
"name": "SicarioSpec Security & Governance Bundle",
|
||||
"id": "sicario-spec",
|
||||
"version": "0.5.1",
|
||||
"role": "security-engineer",
|
||||
"description": "Secure-by-default governance bundle for GitHub Spec Kit. Enforces data classification, threat modeling, and code-owned verification gates.",
|
||||
"author": "SicarioSpec Contributors",
|
||||
"license": "MIT",
|
||||
"download_url": "https://github.com/dfirs1car1o/sicario-spec/releases/download/v0.5.1/sicario-spec-0.5.1.zip",
|
||||
"repository": "https://github.com/dfirs1car1o/sicario-spec",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.9.0"
|
||||
},
|
||||
"provides": {
|
||||
"extensions": 1,
|
||||
"presets": 11,
|
||||
"steps": 0,
|
||||
"workflows": 0
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"governance",
|
||||
"compliance",
|
||||
"appsec",
|
||||
"threat-modeling"
|
||||
],
|
||||
"verified": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ Accepted community bundle entries are published in [`bundles/catalog.community.j
|
||||
|
||||
| Bundle | Purpose | Role or team | Provides | Required catalogs | URL |
|
||||
|--------|---------|--------------|----------|-------------------|-----|
|
||||
| SicarioSpec Security & Governance Bundle | Secure-by-default governance bundle for GitHub Spec Kit. Enforces data classification, threat modeling, and code-owned verification gates. | `security-engineer` | 1 extension, 11 presets | Documented | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
|
||||
|
||||
## What to Submit
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |
|
||||
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
|
||||
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) |
|
||||
| Linear Weave | Weave Spec Kit into Linear: pull requirements, mirror tasks.md into sub-issues, sync statuses | `integration` | Read+Write | [spec-kit-linear-weave](https://github.com/tonydwoodhouse/spec-kit-linear-weave) |
|
||||
| LLM Wiki | LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting | `docs` | Read+Write | [spec-kit-wiki](https://github.com/formin/spec-kit-wiki) |
|
||||
| Loop Engineering | Engineer safe autonomous agent loops for spec-driven development: a maker/checker split, externalized loop state, and stay-the-engineer guardrails against comprehension debt and cognitive surrender | `process` | Read+Write | [spec-kit-loop](https://github.com/formin/spec-kit-loop) |
|
||||
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
|
||||
|
||||
@@ -11,7 +11,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| Agent Parity Governance | Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Architecture Governance | Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery with validated status, stop, resume, exact-head proof, closeout, and learner guidance. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
|
||||
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
|
||||
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
|
||||
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
|
||||
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
|
||||
@@ -19,10 +19,12 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
|
||||
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
|
||||
| Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
|
||||
| Intake Review Governance | Adds hash-bound review, repair, and status gates for single, series, and campaign intake files before interactive, autonomous, or parallel Spec Kit execution. | 8 templates, 3 commands, 2 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
|
||||
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
|
||||
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
|
||||
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |
|
||||
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
|
||||
| Parallel Autonomous Run Governance | Coordinates isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.3.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
|
||||
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
|
||||
| Security Governance | Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
|
||||
@@ -221,12 +221,14 @@ Each hook entry supports the following fields:
|
||||
| `command` | Extension command associated with the hook. |
|
||||
| `enabled` | Whether the hook is active. Hooks with `enabled: false` are skipped. |
|
||||
| `optional` | Whether the hook is optional. If `true`, the hook is presented with its `prompt` and can be skipped; if `false`, the hook is emitted as an automatic hook (includes `EXECUTE_COMMAND` markers). |
|
||||
| `priority` | Priority metadata for the hook. Values must be integers >= 1; invalid values fall back to the default priority `10`. Current command templates surface hooks in their configured YAML order and do not sort them by `priority`. |
|
||||
| `priority` | Priority metadata for the hook. Registered hook entries use integer values >= 1; entries installed from manifests default to `10` when no priority is declared. Current command templates surface hooks in their configured YAML order and do not sort them by `priority`. |
|
||||
| `prompt` | Message shown when asking whether to run an optional hook. |
|
||||
| `description` | Human-readable explanation of what the hook does. |
|
||||
| `condition` | Optional expression evaluated by `HookExecutor` (using `config.<path>` or `env.<VAR>` with `is set`, `==`, or `!=`). Current command templates do not evaluate conditions and skip hooks with a non-empty condition. |
|
||||
Hook event names identify when a hook is invoked. They generally use `before_<command>` or `after_<command>`, such as `before_implement`, `after_implement`, `before_tasks`, and `after_tasks`.
|
||||
|
||||
Extension manifests reject invalid hook priorities during installation. For existing `.specify/extensions.yml` entries, `HookExecutor.get_hooks_for_event()` sorts with `normalize_priority()`: missing values, booleans, non-numeric values rejected by `int()`, and values less than `1` fall back to `10`; numeric strings and finite floats are coerced with `int()`, while non-finite floats are unsupported and may fail instead of falling back.
|
||||
|
||||
`HookExecutor.get_hooks_for_event()` returns hooks ordered by `priority`, with lower values first. However, current command templates read hook lists directly and surface them in their configured YAML order rather than using priority ordering.
|
||||
|
||||
## FAQ
|
||||
|
||||
@@ -252,6 +252,7 @@ Use standard Markdown with special placeholders:
|
||||
|
||||
- `$ARGUMENTS`: User-provided arguments
|
||||
- `{SCRIPT}`: Replaced with script path during registration
|
||||
- `__SPECKIT_COMMAND_<NAME>__`: Replaced with the invocation of another command, rendered using the active integration's separator (see [Referencing other commands](#referencing-other-commands))
|
||||
|
||||
**Example**:
|
||||
|
||||
@@ -267,6 +268,40 @@ echo "Running with args: $args"
|
||||
```
|
||||
````
|
||||
|
||||
### Referencing other commands
|
||||
|
||||
A command body is a *template* that Spec Kit renders once per agent. Different agents invoke commands with different surface syntax — for example `/speckit.plan` (dot separator) or `/speckit-plan` (hyphen separator). Some agents also use different prefixes in skills mode (e.g. Kimi `/skill:speckit-plan`, Codex/ZCode `$speckit-plan`). So when you reference a sibling command from a body, **do not hard-code a literal invocation** like `/speckit.my-ext.prepare`. A literal is correct for exactly one agent and breaks on the rest.
|
||||
|
||||
Instead use the agent-neutral token `__SPECKIT_COMMAND_<NAME>__`. Spec Kit resolves it to a `/speckit<separator>...` invocation using the active integration's `invoke_separator` (and integrations may post-process that further in skills output).
|
||||
|
||||
Encode the command name in upper case, dropping the `speckit.` prefix and turning each dotted segment separator into an underscore:
|
||||
|
||||
| Command file | Token |
|
||||
| --- | --- |
|
||||
| `speckit.plan.md` | `__SPECKIT_COMMAND_PLAN__` |
|
||||
| `speckit.bug.fix.md` | `__SPECKIT_COMMAND_BUG_FIX__` |
|
||||
| `speckit.git.commit.md` | `__SPECKIT_COMMAND_GIT_COMMIT__` |
|
||||
|
||||
The resolver maps each underscore back to the active agent's separator, so use tokens to reference commands whose name segments are single words. (Command names are dotted segments like `git.commit`; the token scheme rebuilds those dots and does not carry hyphens within a segment.)
|
||||
|
||||
**Example** — a command body that points the user at the next step:
|
||||
|
||||
```markdown
|
||||
Once the assessment exists, the next step is `__SPECKIT_COMMAND_BUG_FIX__ slug=<slug>`.
|
||||
```
|
||||
|
||||
This renders as `/speckit.bug.fix slug=<slug>` for a slash-based agent, `/speckit-bug-fix slug=<slug>` for a skills-based agent, and so on — the author writes it once and it stays portable. The first-party `bug` and `git` extensions use this token exclusively; see `extensions/bug/commands/` for working examples.
|
||||
|
||||
> **Current limitation — skills mode.** Token resolution runs in the
|
||||
> command-rendering path (`CommandRegistrar`), so it applies when an extension
|
||||
> installs *command files*. It does **not** yet run when an extension is
|
||||
> registered as *skills* for a skills-based agent: `_register_extension_skills`
|
||||
> resolves placeholders and post-processes content but never calls
|
||||
> `resolve_command_refs`, so a `__SPECKIT_COMMAND_<NAME>__` token reaches
|
||||
> agents such as Codex, ZCode, and Kimi verbatim in that mode. Until that
|
||||
> rendering step lands, prefer the token for command-file extensions and avoid
|
||||
> relying on it inside skill bodies destined for skills-based agents.
|
||||
|
||||
### Script Path Rewriting
|
||||
|
||||
Extension commands use relative paths that get rewritten during registration:
|
||||
|
||||
@@ -2029,6 +2029,40 @@
|
||||
"created_at": "2026-06-01T00:00:00Z",
|
||||
"updated_at": "2026-06-22T00:00:00Z"
|
||||
},
|
||||
"linear-weave": {
|
||||
"name": "Linear Weave",
|
||||
"id": "linear-weave",
|
||||
"description": "Weave Spec Kit into Linear: pull requirements, mirror tasks.md into sub-issues, sync statuses.",
|
||||
"author": "Tony Woodhouse",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/tonydwoodhouse/spec-kit-linear-weave/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/tonydwoodhouse/spec-kit-linear-weave",
|
||||
"homepage": "https://github.com/tonydwoodhouse/spec-kit-linear-weave",
|
||||
"documentation": "https://github.com/tonydwoodhouse/spec-kit-linear-weave#readme",
|
||||
"changelog": "https://github.com/tonydwoodhouse/spec-kit-linear-weave/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "integration",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.13.0,<1.0.0",
|
||||
"tools": [{ "name": "linear-mcp", "required": true }]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 5
|
||||
},
|
||||
"tags": [
|
||||
"linear",
|
||||
"issue-tracking",
|
||||
"integration",
|
||||
"workflow"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-21T00:00:00Z",
|
||||
"updated_at": "2026-07-21T00:00:00Z"
|
||||
},
|
||||
"loop": {
|
||||
"name": "Loop Engineering",
|
||||
"id": "loop",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-17T00:00:00Z",
|
||||
"updated_at": "2026-07-22T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
@@ -69,7 +69,7 @@
|
||||
"name": "AIDE In-Place Migration",
|
||||
"id": "aide-in-place",
|
||||
"version": "1.0.0",
|
||||
"description": "Adapts the AIDE workflow for in-place technology migrations (X → Y pattern). Overrides vision, roadmap, progress, and work item commands with migration-specific guidance.",
|
||||
"description": "Adapts the AIDE workflow for in-place technology migrations (X \u2192 Y pattern). Overrides vision, roadmap, progress, and work item commands with migration-specific guidance.",
|
||||
"author": "mnriem",
|
||||
"repository": "https://github.com/mnriem/spec-kit-presets",
|
||||
"download_url": "https://github.com/mnriem/spec-kit-presets/releases/download/aide-in-place-v1.0.0/aide-in-place.zip",
|
||||
@@ -134,13 +134,13 @@
|
||||
"autonomous-run-governance": {
|
||||
"name": "Autonomous Run Governance",
|
||||
"id": "autonomous-run-governance",
|
||||
"version": "0.2.2",
|
||||
"description": "Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery with validated status, stop, resume, exact-head proof, closeout, and learner guidance.",
|
||||
"version": "0.3.2",
|
||||
"description": "Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.2.2.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.3.2.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.2.2/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.3.2/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
@@ -155,10 +155,11 @@
|
||||
"governance",
|
||||
"evidence",
|
||||
"permissions",
|
||||
"resume"
|
||||
"resume",
|
||||
"intake-review"
|
||||
],
|
||||
"created_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-07-17T00:00:00Z"
|
||||
"updated_at": "2026-07-21T00:00:00Z"
|
||||
},
|
||||
"canon-core": {
|
||||
"name": "Canon Core",
|
||||
@@ -363,6 +364,35 @@
|
||||
"created_at": "2026-05-05T08:00:00Z",
|
||||
"updated_at": "2026-06-22T00:00:00Z"
|
||||
},
|
||||
"intake-review-governance": {
|
||||
"name": "Intake Review Governance",
|
||||
"id": "intake-review-governance",
|
||||
"version": "0.1.0",
|
||||
"description": "Adds hash-bound review, repair, and status gates for single, series, and campaign intake files before interactive, autonomous, or parallel Spec Kit execution.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/archive/refs/tags/v0.1.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/blob/v0.1.0/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 8,
|
||||
"commands": 3,
|
||||
"scripts": 2
|
||||
},
|
||||
"tags": [
|
||||
"intake",
|
||||
"review",
|
||||
"governance",
|
||||
"quality-gate",
|
||||
"autonomous"
|
||||
],
|
||||
"created_at": "2026-07-21T00:00:00Z",
|
||||
"updated_at": "2026-07-21T00:00:00Z"
|
||||
},
|
||||
"isaqb-architecture-governance": {
|
||||
"name": "iSAQB Architecture Governance",
|
||||
"id": "isaqb-architecture-governance",
|
||||
@@ -480,6 +510,36 @@
|
||||
"created_at": "2026-04-09T00:00:00Z",
|
||||
"updated_at": "2026-04-09T00:00:00Z"
|
||||
},
|
||||
"parallel-autonomous-run-governance": {
|
||||
"name": "Parallel Autonomous Run Governance",
|
||||
"id": "parallel-autonomous-run-governance",
|
||||
"version": "0.2.3",
|
||||
"description": "Coordinates isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/archive/refs/tags/v0.2.3.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/blob/v0.2.3/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 9,
|
||||
"commands": 5,
|
||||
"scripts": 2
|
||||
},
|
||||
"tags": [
|
||||
"parallel",
|
||||
"autonomous",
|
||||
"governance",
|
||||
"orchestration",
|
||||
"resume",
|
||||
"intake-review"
|
||||
],
|
||||
"created_at": "2026-07-22T00:00:00Z",
|
||||
"updated_at": "2026-07-22T00:00:00Z"
|
||||
},
|
||||
"pirate": {
|
||||
"name": "Pirate Speak (Full)",
|
||||
"id": "pirate",
|
||||
@@ -509,7 +569,7 @@
|
||||
"name": "Screenwriting",
|
||||
"id": "screenwriting",
|
||||
"version": "1.0.0",
|
||||
"description": "Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents replace prose fiction conventions. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks.",
|
||||
"description": "Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft \u2014 slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents replace prose fiction conventions. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks.",
|
||||
"author": "Andreas Daumann",
|
||||
"repository": "https://github.com/adaumann/speckit-preset-screenwriting",
|
||||
"download_url": "https://github.com/adaumann/speckit-preset-screenwriting/archive/refs/tags/v1.0.0.zip",
|
||||
@@ -624,7 +684,7 @@
|
||||
"name": "Spec2Cloud",
|
||||
"id": "spec2cloud",
|
||||
"version": "1.1.0",
|
||||
"description": "Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy.",
|
||||
"description": "Spec-driven workflow tuned for shipping to Azure: spec \u2192 plan \u2192 tasks \u2192 implement \u2192 deploy.",
|
||||
"author": "Azure Samples",
|
||||
"repository": "https://github.com/Azure-Samples/Spec2Cloud",
|
||||
"download_url": "https://github.com/Azure-Samples/Spec2Cloud/releases/download/spec-kit-spec2cloud-v1.1.0/preset.zip",
|
||||
@@ -652,7 +712,7 @@
|
||||
"id": "test-first-governance",
|
||||
"version": "1.3.0",
|
||||
"description": "Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates.",
|
||||
"author": "Zoltán Katona, PhD",
|
||||
"author": "Zolt\u00e1n Katona, PhD",
|
||||
"repository": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",
|
||||
"download_url": "https://github.com/ka-zo/spec-kit-preset-test-first-governance/archive/refs/tags/1.3.0.zip",
|
||||
"homepage": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.13.2.dev0"
|
||||
version = "0.13.3"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import typer
|
||||
from rich.markup import escape
|
||||
|
||||
from .._agent_config import SCRIPT_TYPE_CHOICES
|
||||
from .._console import console
|
||||
@@ -206,7 +207,7 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str,
|
||||
while i < len(tokens):
|
||||
token = tokens[i]
|
||||
if not token.startswith("-"):
|
||||
console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.")
|
||||
console.print(f"[red]Error:[/red] Unexpected integration option value '{escape(token)}'.")
|
||||
if allowed:
|
||||
console.print(f"Allowed options: {allowed}")
|
||||
raise typer.Exit(1)
|
||||
@@ -217,7 +218,7 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str,
|
||||
name, value = name.split("=", 1)
|
||||
opt = declared.get(name)
|
||||
if not opt:
|
||||
console.print(f"[red]Error:[/red] Unknown integration option '{token}'.")
|
||||
console.print(f"[red]Error:[/red] Unknown integration option '{escape(token)}'.")
|
||||
if allowed:
|
||||
console.print(f"Allowed options: {allowed}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -523,8 +523,20 @@ class WorkflowCatalog:
|
||||
|
||||
_validate_catalog_url(entry.url)
|
||||
|
||||
# Validate EVERY redirect hop, not just the final URL: _open_url follows
|
||||
# redirects, so an https:// entry that 30x-redirects through http:// (or
|
||||
# to a non-HTTPS host mid-chain) could otherwise let a network attacker
|
||||
# rewrite the next hop and slip a payload past a final-URL-only check.
|
||||
# redirect_validator runs before each hop; the geturl() check below is
|
||||
# retained as a defense-in-depth backstop. Mirrors the presets/extensions
|
||||
# catalog fix (#3523 / #3524).
|
||||
def _validate_redirect(_old_url: str, new_url: str) -> None:
|
||||
_validate_catalog_url(new_url)
|
||||
|
||||
try:
|
||||
with _open_url(entry.url, timeout=30) as resp:
|
||||
with _open_url(
|
||||
entry.url, timeout=30, redirect_validator=_validate_redirect
|
||||
) as resp:
|
||||
_validate_catalog_url(resp.geturl())
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as exc:
|
||||
@@ -1180,8 +1192,20 @@ class StepCatalog:
|
||||
|
||||
_validate_url(entry.url)
|
||||
|
||||
# Validate EVERY redirect hop, not just the final URL: _open_url follows
|
||||
# redirects, so an https:// entry that 30x-redirects through http:// (or
|
||||
# to a non-HTTPS host mid-chain) could otherwise let a network attacker
|
||||
# rewrite the next hop and slip a payload past a final-URL-only check.
|
||||
# redirect_validator runs before each hop; the geturl() check below is
|
||||
# retained as a defense-in-depth backstop. Mirrors the presets/extensions
|
||||
# catalog fix (#3523 / #3524).
|
||||
def _validate_redirect(_old_url: str, new_url: str) -> None:
|
||||
_validate_url(new_url)
|
||||
|
||||
try:
|
||||
with _open_url(entry.url, timeout=30) as resp:
|
||||
with _open_url(
|
||||
entry.url, timeout=30, redirect_validator=_validate_redirect
|
||||
) as resp:
|
||||
_validate_url(resp.geturl())
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as exc:
|
||||
|
||||
@@ -201,6 +201,20 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
|
||||
f"Must be 'string', 'number', or 'boolean'."
|
||||
)
|
||||
|
||||
# ``enum`` must be a list. Checked here — not only via the
|
||||
# ``_coerce_input`` call below — because that call is reached only
|
||||
# when a ``default`` is present, and the ``integration: auto`` case
|
||||
# strips ``enum`` before coercing; a scalar/string ``enum`` on an
|
||||
# input with no default (or the auto-integration default) would
|
||||
# otherwise slip through here and then crash ``_resolve_inputs`` with
|
||||
# a raw ``TypeError`` at run time. ``None`` means "no enum".
|
||||
enum_values = input_def.get("enum")
|
||||
if enum_values is not None and not isinstance(enum_values, list):
|
||||
errors.append(
|
||||
f"Input {input_name!r} has invalid 'enum': must be a list, "
|
||||
f"got {type(enum_values).__name__}."
|
||||
)
|
||||
|
||||
# Validate the default eagerly so authoring mistakes (e.g. a
|
||||
# default not in the declared enum, or a non-numeric default for
|
||||
# a number input) surface at install/validation time instead of
|
||||
@@ -209,13 +223,28 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
|
||||
# enum-membership check is exempted for that exact case — the
|
||||
# declared type is still enforced (e.g. ``type: number`` paired
|
||||
# with ``default: "auto"`` is still rejected).
|
||||
enum_is_valid = enum_values is None or isinstance(enum_values, list)
|
||||
if "default" in input_def:
|
||||
default_value = input_def["default"]
|
||||
is_auto_integration = (
|
||||
input_name == "integration" and default_value == "auto"
|
||||
)
|
||||
# Strip ``enum`` from the definition handed to ``_coerce_input``
|
||||
# when either:
|
||||
# * this is the auto-integration sentinel (enum-membership is
|
||||
# a runtime concern, exempted for ``"auto"``), or
|
||||
# * the ``enum`` is malformed (non-list) and already reported
|
||||
# above — leaving it in would make ``_coerce_input`` re-raise
|
||||
# the same enum-shape error re-framed as an "invalid default"
|
||||
# (a confusing duplicate).
|
||||
# Removing *only* ``enum`` (rather than skipping the check
|
||||
# entirely) preserves the default's type validation: a
|
||||
# ``type: string`` input with ``default: 5, enum: 5`` still
|
||||
# reports the wrong-typed default alongside the enum error,
|
||||
# instead of hiding it.
|
||||
strip_enum = is_auto_integration or not enum_is_valid
|
||||
validation_input_def: dict[str, Any] = input_def
|
||||
if is_auto_integration and "enum" in input_def:
|
||||
if strip_enum and "enum" in input_def:
|
||||
validation_input_def = {
|
||||
key: value
|
||||
for key, value in input_def.items()
|
||||
@@ -1400,11 +1429,18 @@ class WorkflowEngine:
|
||||
# definition (``string`` rejects non-strings, ``number`` rejects
|
||||
# bools and uncoercible values, ``boolean`` rejects non-bools),
|
||||
# so ill-typed values still fail fast here.
|
||||
#
|
||||
# ``execute()`` accepts unvalidated definitions, so a malformed
|
||||
# (non-list) ``enum`` can reach here. Only strip a *list* ``enum``:
|
||||
# a scalar/string ``enum`` must stay in the definition so
|
||||
# ``_coerce_input`` raises the clean shape ``ValueError`` instead of
|
||||
# being silently exempted by the ``auto`` membership skip (which
|
||||
# would otherwise let ``enum: 5`` resolve successfully).
|
||||
coerce_input_def = input_def
|
||||
if (
|
||||
name == "integration"
|
||||
and value == "auto"
|
||||
and "enum" in input_def
|
||||
and isinstance(input_def.get("enum"), list)
|
||||
):
|
||||
coerce_input_def = {
|
||||
key: val
|
||||
@@ -1450,6 +1486,22 @@ class WorkflowEngine:
|
||||
input_type = input_def.get("type", "string")
|
||||
enum_values = input_def.get("enum")
|
||||
|
||||
# ``enum`` must be a list. A scalar (``enum: 5``, ``enum: true``) makes
|
||||
# the ``value not in enum_values`` membership test below raise a raw
|
||||
# ``TypeError`` ("argument of type 'int' is not ... iterable"), which
|
||||
# escapes ``validate_workflow``'s ``except ValueError`` and breaks its
|
||||
# "return errors, never raise" contract — and crashes ``_resolve_inputs``
|
||||
# outright at run time. A bare string is just as wrong: ``value in "abc"``
|
||||
# is a silent substring/character test, not enum membership. Require a
|
||||
# list so both forms fail fast with a clear message. ``None`` means "no
|
||||
# enum" and is left alone.
|
||||
if enum_values is not None and not isinstance(enum_values, list):
|
||||
msg = (
|
||||
f"Input {name!r} has invalid 'enum': must be a list, got "
|
||||
f"{type(enum_values).__name__}."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
if input_type == "number":
|
||||
# Reject bools explicitly: ``bool`` is a subclass of ``int`` so
|
||||
# ``float(True)`` succeeds and would silently coerce a YAML
|
||||
|
||||
@@ -66,16 +66,52 @@ class CommandStep(StepBase):
|
||||
for key, value in input_data.items():
|
||||
resolved_input[key] = evaluate_expression(value, context)
|
||||
|
||||
# Resolve integration (step → workflow default → project default)
|
||||
integration = config.get("integration") or context.default_integration
|
||||
# Resolve integration (step → workflow default → project default).
|
||||
# Fall back to the workflow default ONLY for a genuinely-unset value
|
||||
# (missing / YAML-null / empty string). A ``config.get(...) or ...``
|
||||
# would also swallow a falsey *non-string* ([], {}, 0, False), coercing
|
||||
# it to the default before the guard below runs — so on an unvalidated
|
||||
# execute() such a step would silently dispatch with the configured
|
||||
# default instead of failing. Fall through instead, so every non-string
|
||||
# reaches the type guard.
|
||||
integration = config.get("integration")
|
||||
if integration is None or integration == "":
|
||||
integration = context.default_integration
|
||||
if integration and isinstance(integration, str) and "{{" in integration:
|
||||
integration = evaluate_expression(integration, context)
|
||||
|
||||
# Resolve model
|
||||
model = config.get("model") or context.default_model
|
||||
# Resolve model (same fallback rationale as 'integration' above).
|
||||
model = config.get("model")
|
||||
if model is None or model == "":
|
||||
model = context.default_model
|
||||
if model and isinstance(model, str) and "{{" in model:
|
||||
model = evaluate_expression(model, context)
|
||||
|
||||
# A non-string integration/model — a literal list/dict/number that
|
||||
# skipped validation, an unvalidated workflow-level default, or an
|
||||
# expression that resolved to one — crashes downstream: get_integration()
|
||||
# uses the value as a dict key (raw TypeError on an unhashable list/dict,
|
||||
# even on a *validated* run) and build_exec_args() feeds model into the
|
||||
# CLI argv. Fail the step with the contract error rather than taking down
|
||||
# the whole run, mirroring the 'input'/'options' guards above. ``None``
|
||||
# stays valid — it means "unset" and falls back to dispatch-not-possible.
|
||||
if integration is not None and not isinstance(integration, str):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Command step {config.get('id', '?')!r}: 'integration' must "
|
||||
f"be a string, got {type(integration).__name__}."
|
||||
),
|
||||
)
|
||||
if model is not None and not isinstance(model, str):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Command step {config.get('id', '?')!r}: 'model' must be a "
|
||||
f"string, got {type(model).__name__}."
|
||||
),
|
||||
)
|
||||
|
||||
# Merge options (workflow defaults ← step overrides)
|
||||
options = dict(context.default_options)
|
||||
step_options = config.get("options", {})
|
||||
@@ -217,4 +253,23 @@ class CommandStep(StepBase):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r}: 'options' must be a mapping."
|
||||
)
|
||||
# execute() passes 'integration' to get_integration(), which uses it as a
|
||||
# dict key — a non-string (list/dict) raises a raw TypeError (unhashable),
|
||||
# even on a validated run — and feeds 'model' into the CLI argv. Reject a
|
||||
# literal non-string here, mirroring the sibling type checks. ``None``
|
||||
# (an explicit ``integration:``/``model:`` YAML null) means "inherit the
|
||||
# workflow default" and stays valid; an expression like "{{ ... }}" is
|
||||
# still a str, so it stays valid too.
|
||||
integration = config.get("integration")
|
||||
if integration is not None and not isinstance(integration, str):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r}: 'integration' must be a "
|
||||
f"string, got {type(integration).__name__}."
|
||||
)
|
||||
model = config.get("model")
|
||||
if model is not None and not isinstance(model, str):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r}: 'model' must be a "
|
||||
f"string, got {type(model).__name__}."
|
||||
)
|
||||
return errors
|
||||
|
||||
@@ -12,9 +12,10 @@ class FanOutStep(StepBase):
|
||||
"""Dispatch a step template for each item in a collection.
|
||||
|
||||
The engine executes the nested ``step:`` template once per item,
|
||||
setting ``context.item`` for each iteration. Execution is
|
||||
currently sequential; ``max_concurrency`` is accepted but not
|
||||
enforced.
|
||||
setting ``context.item`` for each iteration. ``max_concurrency``
|
||||
controls parallelism: ``<= 1`` (the default) runs items
|
||||
sequentially, while ``> 1`` runs up to that many items concurrently
|
||||
on a bounded thread pool (see ``WorkflowEngine._run_fan_out``).
|
||||
"""
|
||||
|
||||
type_key = "fan-out"
|
||||
|
||||
@@ -42,16 +42,52 @@ class PromptStep(StepBase):
|
||||
if not isinstance(prompt, str):
|
||||
prompt = str(prompt)
|
||||
|
||||
# Resolve integration (step → workflow default)
|
||||
integration = config.get("integration") or context.default_integration
|
||||
# Resolve integration (step → workflow default).
|
||||
# Fall back to the workflow default ONLY for a genuinely-unset value
|
||||
# (missing / YAML-null / empty string). A ``config.get(...) or ...``
|
||||
# would also swallow a falsey *non-string* ([], {}, 0, False), coercing
|
||||
# it to the default before the guard below runs — so on an unvalidated
|
||||
# execute() such a step would silently dispatch with the configured
|
||||
# default instead of failing. Fall through instead, so every non-string
|
||||
# reaches the type guard.
|
||||
integration = config.get("integration")
|
||||
if integration is None or integration == "":
|
||||
integration = context.default_integration
|
||||
if integration and isinstance(integration, str) and "{{" in integration:
|
||||
integration = evaluate_expression(integration, context)
|
||||
|
||||
# Resolve model
|
||||
model = config.get("model") or context.default_model
|
||||
# Resolve model (same fallback rationale as 'integration' above).
|
||||
model = config.get("model")
|
||||
if model is None or model == "":
|
||||
model = context.default_model
|
||||
if model and isinstance(model, str) and "{{" in model:
|
||||
model = evaluate_expression(model, context)
|
||||
|
||||
# A non-string integration/model — a literal list/dict/number that
|
||||
# skipped validation, an unvalidated workflow-level default, or an
|
||||
# expression that resolved to one — crashes downstream: get_integration()
|
||||
# uses the value as a dict key (raw TypeError on an unhashable list/dict,
|
||||
# even on a *validated* run) and build_exec_args() feeds model into the
|
||||
# CLI argv. Fail the step with the contract error rather than taking down
|
||||
# the whole run. ``None`` stays valid — it means "unset" and falls back
|
||||
# to dispatch-not-possible.
|
||||
if integration is not None and not isinstance(integration, str):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Prompt step {config.get('id', '?')!r}: 'integration' must "
|
||||
f"be a string, got {type(integration).__name__}."
|
||||
),
|
||||
)
|
||||
if model is not None and not isinstance(model, str):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Prompt step {config.get('id', '?')!r}: 'model' must be a "
|
||||
f"string, got {type(model).__name__}."
|
||||
),
|
||||
)
|
||||
|
||||
# Attempt CLI dispatch
|
||||
dispatch_result = self._try_dispatch(
|
||||
prompt, integration, model, context
|
||||
@@ -172,4 +208,23 @@ class PromptStep(StepBase):
|
||||
f"Prompt step {config.get('id', '?')!r}: 'prompt' must be a "
|
||||
f"string, got {type(config['prompt']).__name__}."
|
||||
)
|
||||
# execute() passes 'integration' to get_integration(), which uses it as a
|
||||
# dict key — a non-string (list/dict) raises a raw TypeError (unhashable),
|
||||
# even on a validated run — and feeds 'model' into the CLI argv. Reject a
|
||||
# literal non-string here, mirroring the 'prompt' check above. ``None``
|
||||
# (an explicit ``integration:``/``model:`` YAML null) means "inherit the
|
||||
# workflow default" and stays valid; an expression like "{{ ... }}" is
|
||||
# still a str, so it stays valid too.
|
||||
integration = config.get("integration")
|
||||
if integration is not None and not isinstance(integration, str):
|
||||
errors.append(
|
||||
f"Prompt step {config.get('id', '?')!r}: 'integration' must be a "
|
||||
f"string, got {type(integration).__name__}."
|
||||
)
|
||||
model = config.get("model")
|
||||
if model is not None and not isinstance(model, str):
|
||||
errors.append(
|
||||
f"Prompt step {config.get('id', '?')!r}: 'model' must be a "
|
||||
f"string, got {type(model).__name__}."
|
||||
)
|
||||
return errors
|
||||
|
||||
@@ -3119,6 +3119,30 @@ class TestParseIntegrationOptionsEqualsForm:
|
||||
assert excinfo.value.exit_code == 1
|
||||
assert "Error: Could not parse integration options: No closing quotation." in capsys.readouterr().out
|
||||
|
||||
def test_bad_option_token_with_rich_markup_exits_cleanly(self):
|
||||
"""A bad option token carrying Rich markup must exit cleanly, not crash.
|
||||
|
||||
The token is user-controlled and gets interpolated into console.print.
|
||||
A value like '[/red]foo' parses fine through shlex but is an unexpected
|
||||
value / unknown option — and an unbalanced Rich tag would raise
|
||||
rich.errors.MarkupError inside console.print, leaking a traceback
|
||||
instead of the intended typer.Exit(1). The token must be escaped."""
|
||||
import typer
|
||||
|
||||
from specify_cli.integrations._commands import _parse_integration_options
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
integration = get_integration("generic")
|
||||
assert integration is not None
|
||||
|
||||
# Unexpected value token carrying markup.
|
||||
with pytest.raises(typer.Exit):
|
||||
_parse_integration_options(integration, "[/red]foo")
|
||||
|
||||
# Unknown option token carrying markup.
|
||||
with pytest.raises(typer.Exit):
|
||||
_parse_integration_options(integration, "--[/red]bad")
|
||||
|
||||
|
||||
class TestUninstallNoManifestClearsInitOptions:
|
||||
def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path):
|
||||
|
||||
349
tests/test_security_workflow.py
Normal file
349
tests/test_security_workflow.py
Normal file
@@ -0,0 +1,349 @@
|
||||
"""Static checks for the dependency-audit security workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SECURITY_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "security.yml"
|
||||
CONTRIBUTING = REPO_ROOT / "CONTRIBUTING.md"
|
||||
SECURITY_REQUIREMENTS = REPO_ROOT / ".github" / "security-audit-requirements.txt"
|
||||
SECURITY_REQUIREMENTS_SYNC_SCRIPT = (
|
||||
REPO_ROOT / ".github" / "scripts" / "check_security_requirements.py"
|
||||
)
|
||||
|
||||
WORKFLOW_LIVE_AUDIT_REQUIREMENTS = '"${{ runner.temp }}/spec-kit-audit-requirements.txt"'
|
||||
COMMITTED_AUDIT_REQUIREMENTS = ".github/security-audit-requirements.txt"
|
||||
WORKFLOW_COMPILE_SCHEDULED_TEST_EXTRA_DEPS = (
|
||||
"uv pip compile pyproject.toml --extra test "
|
||||
'--python-version "${{ matrix.python-version }}" --upgrade --generate-hashes --quiet '
|
||||
f"--output-file {WORKFLOW_LIVE_AUDIT_REQUIREMENTS}"
|
||||
)
|
||||
LOCAL_REFRESH_TEST_EXTRA_DEPS = (
|
||||
"uv pip compile pyproject.toml --extra test --universal --upgrade --generate-hashes "
|
||||
f"--quiet --no-header --output-file {COMMITTED_AUDIT_REQUIREMENTS}"
|
||||
)
|
||||
WORKFLOW_SYNC_COMPILE_TEST_EXTRA_DEPS = (
|
||||
"uv pip compile pyproject.toml --extra test --universal --upgrade --generate-hashes "
|
||||
"--quiet --no-header --output-file"
|
||||
)
|
||||
WORKFLOW_SYNC_SCRIPT = "python .github/scripts/check_security_requirements.py"
|
||||
WORKFLOW_LIVE_PIP_AUDIT = (
|
||||
"uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes "
|
||||
f"-r {WORKFLOW_LIVE_AUDIT_REQUIREMENTS} --progress-spinner off"
|
||||
)
|
||||
LOCAL_PIP_AUDIT = (
|
||||
"uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes "
|
||||
f"-r {COMMITTED_AUDIT_REQUIREMENTS} --progress-spinner off"
|
||||
)
|
||||
|
||||
|
||||
def _load_security_workflow() -> dict:
|
||||
return yaml.safe_load(SECURITY_WORKFLOW.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _workflow_triggers() -> dict:
|
||||
workflow = _load_security_workflow()
|
||||
return workflow.get("on") or workflow[True]
|
||||
|
||||
|
||||
def _step(job_name: str, step_name: str) -> dict:
|
||||
workflow = _load_security_workflow()
|
||||
for step in workflow["jobs"][job_name]["steps"]:
|
||||
if step.get("name") == step_name:
|
||||
return step
|
||||
raise AssertionError(f"Step {step_name!r} not found in job {job_name!r}.")
|
||||
|
||||
|
||||
def _job_run_text(*job_names: str) -> str:
|
||||
workflow = _load_security_workflow()
|
||||
return "\n".join(
|
||||
step.get("run", "")
|
||||
for job_name in job_names
|
||||
for step in workflow["jobs"][job_name]["steps"]
|
||||
)
|
||||
|
||||
|
||||
def _load_sync_script():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"check_security_requirements",
|
||||
SECURITY_REQUIREMENTS_SYNC_SCRIPT,
|
||||
)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class TestDependencyAuditWorkflow:
|
||||
"""Guard the dependency-audit security workflow."""
|
||||
|
||||
def test_dependency_audit_uses_committed_requirements_for_prs_and_pushes(self):
|
||||
workflow = _load_security_workflow()
|
||||
job = workflow["jobs"]["dependency-audit"]
|
||||
committed_audit = _step("dependency-audit", "Run pip-audit (committed requirements)")
|
||||
sync_check = _step("dependency-audit", "Check committed audit requirements are current")
|
||||
setup_python = _step("dependency-audit", "Set up Python")
|
||||
|
||||
assert job["if"] == "${{ github.event_name != 'schedule' }}"
|
||||
assert job["runs-on"] == "ubuntu-latest"
|
||||
assert "strategy" not in job
|
||||
assert setup_python["with"]["python-version"] == "3.14"
|
||||
assert sync_check["env"]["DEPENDENCY_DIFF_BASE"] == (
|
||||
"${{ github.event.pull_request.base.sha || github.event.before || '' }}"
|
||||
)
|
||||
assert sync_check["env"]["DEPENDENCY_DIFF_HEAD"] == "${{ github.sha }}"
|
||||
assert sync_check["run"] == WORKFLOW_SYNC_SCRIPT
|
||||
assert committed_audit["run"] == LOCAL_PIP_AUDIT
|
||||
|
||||
dependency_job_text = _job_run_text(
|
||||
"dependency-audit",
|
||||
"dependency-audit-scheduled",
|
||||
)
|
||||
protection_text = (
|
||||
dependency_job_text
|
||||
+ "\n"
|
||||
+ SECURITY_REQUIREMENTS_SYNC_SCRIPT.read_text(encoding="utf-8")
|
||||
)
|
||||
assert "--generate-hashes" in protection_text
|
||||
assert "--no-header" in protection_text
|
||||
assert "--require-hashes" in protection_text
|
||||
assert "--disable-pip" in protection_text
|
||||
assert WORKFLOW_LIVE_AUDIT_REQUIREMENTS in dependency_job_text
|
||||
assert COMMITTED_AUDIT_REQUIREMENTS in protection_text
|
||||
assert "uv export" not in protection_text
|
||||
assert "--frozen" not in protection_text
|
||||
assert "--locked" not in protection_text
|
||||
assert "uv.lock" not in protection_text
|
||||
assert "/tmp/" not in protection_text
|
||||
|
||||
def test_dependency_audit_checkout_fetches_full_history_for_diff_base(self):
|
||||
checkout = _step("dependency-audit", "Checkout")
|
||||
|
||||
assert checkout["with"]["fetch-depth"] == 0
|
||||
|
||||
def test_security_workflow_triggers(self):
|
||||
triggers = _workflow_triggers()
|
||||
|
||||
assert triggers["push"]["branches"] == ["main"]
|
||||
# Asserted by inclusion so later PRs (e.g. baseline-growth gates) can add
|
||||
# labeled/unlabeled without rewriting this test.
|
||||
assert {"opened", "synchronize", "reopened"} <= set(
|
||||
triggers["pull_request"]["types"]
|
||||
)
|
||||
assert "workflow_dispatch" in triggers
|
||||
assert triggers["schedule"] == [{"cron": "17 4 * * 1"}]
|
||||
|
||||
def test_scheduled_dependency_audit_runs_supported_python_os_matrix(self):
|
||||
workflow = _load_security_workflow()
|
||||
job = workflow["jobs"]["dependency-audit-scheduled"]
|
||||
matrix = job["strategy"]["matrix"]
|
||||
scheduled_compile = _step(
|
||||
"dependency-audit-scheduled",
|
||||
"Compile scheduled audit requirements",
|
||||
)
|
||||
scheduled_audit = _step(
|
||||
"dependency-audit-scheduled",
|
||||
"Run pip-audit (scheduled live resolution)",
|
||||
)
|
||||
|
||||
assert job["if"] == "${{ github.event_name == 'schedule' }}"
|
||||
assert matrix["os"] == ["ubuntu-latest", "windows-latest"]
|
||||
assert matrix["python-version"] == ["3.11", "3.12", "3.13", "3.14"]
|
||||
assert job["runs-on"] == "${{ matrix.os }}"
|
||||
assert WORKFLOW_COMPILE_SCHEDULED_TEST_EXTRA_DEPS in scheduled_compile["run"]
|
||||
assert scheduled_audit["run"] == WORKFLOW_LIVE_PIP_AUDIT
|
||||
|
||||
def test_pip_audit_is_pinned(self):
|
||||
workflow_text = SECURITY_WORKFLOW.read_text(encoding="utf-8")
|
||||
|
||||
assert WORKFLOW_LIVE_PIP_AUDIT in workflow_text
|
||||
assert LOCAL_PIP_AUDIT in workflow_text
|
||||
assert re.search(r"\buvx\s+pip-audit\b", workflow_text) is None
|
||||
|
||||
def test_actions_are_pinned_to_full_commit_shas(self):
|
||||
workflow = _load_security_workflow()
|
||||
uses_refs = [
|
||||
step["uses"]
|
||||
for job in workflow["jobs"].values()
|
||||
for step in job["steps"]
|
||||
if "uses" in step
|
||||
]
|
||||
|
||||
assert uses_refs
|
||||
for uses_ref in uses_refs:
|
||||
assert re.search(r"@[0-9a-f]{40}$", uses_ref), uses_ref
|
||||
assert re.search(r"@v\d+", uses_ref) is None
|
||||
|
||||
def test_setup_python_pin_matches_repo_standard(self):
|
||||
workflow = _load_security_workflow()
|
||||
security_refs = {
|
||||
step["uses"]
|
||||
for job in workflow["jobs"].values()
|
||||
for step in job["steps"]
|
||||
if step.get("uses", "").startswith("actions/setup-python@")
|
||||
}
|
||||
repo_standard_refs = set()
|
||||
for workflow_path in (
|
||||
REPO_ROOT / ".github" / "workflows" / "test.yml",
|
||||
REPO_ROOT / ".github" / "workflows" / "publish-pypi.yml",
|
||||
):
|
||||
workflow_data = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
|
||||
repo_standard_refs.update(
|
||||
step["uses"]
|
||||
for job in workflow_data["jobs"].values()
|
||||
for step in job["steps"]
|
||||
if step.get("uses", "").startswith("actions/setup-python@")
|
||||
)
|
||||
|
||||
assert len(repo_standard_refs) == 1
|
||||
assert security_refs == repo_standard_refs
|
||||
|
||||
def test_setup_uv_pin_matches_repo_standard(self):
|
||||
workflow = _load_security_workflow()
|
||||
security_refs = {
|
||||
step["uses"]
|
||||
for job in workflow["jobs"].values()
|
||||
for step in job["steps"]
|
||||
if step.get("uses", "").startswith("astral-sh/setup-uv@")
|
||||
}
|
||||
test_workflow = yaml.safe_load(
|
||||
(REPO_ROOT / ".github" / "workflows" / "test.yml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
repo_standard_refs = {
|
||||
step["uses"]
|
||||
for job in test_workflow["jobs"].values()
|
||||
for step in job["steps"]
|
||||
if step.get("uses", "").startswith("astral-sh/setup-uv@")
|
||||
}
|
||||
|
||||
assert len(repo_standard_refs) == 1
|
||||
assert security_refs == repo_standard_refs
|
||||
|
||||
def test_committed_audit_requirements_are_hashed(self):
|
||||
requirements = SECURITY_REQUIREMENTS.read_text(encoding="utf-8")
|
||||
|
||||
assert "--hash=sha256:" in requirements
|
||||
assert not requirements.startswith("#")
|
||||
assert "pytest==" in requirements
|
||||
assert "pytest-cov==" in requirements
|
||||
|
||||
def test_sync_script_skips_when_dependency_inputs_are_unchanged(self, monkeypatch, capsys):
|
||||
sync_script = _load_sync_script()
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
assert command == [
|
||||
"git", "diff", "--name-only", "HEAD^", "HEAD", "--",
|
||||
"pyproject.toml", ".github/security-audit-requirements.txt",
|
||||
]
|
||||
assert kwargs["check"] is True
|
||||
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
|
||||
|
||||
assert sync_script.main() == 0
|
||||
assert "sync check skipped" in capsys.readouterr().out
|
||||
|
||||
def test_sync_script_uses_github_diff_refs_when_available(self, monkeypatch):
|
||||
sync_script = _load_sync_script()
|
||||
monkeypatch.setenv("DEPENDENCY_DIFF_BASE", "abc123")
|
||||
monkeypatch.setenv("DEPENDENCY_DIFF_HEAD", "def456")
|
||||
|
||||
def fake_run(command, **_kwargs):
|
||||
assert command == [
|
||||
"git", "diff", "--name-only", "abc123", "def456", "--",
|
||||
"pyproject.toml", ".github/security-audit-requirements.txt",
|
||||
]
|
||||
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
|
||||
|
||||
assert sync_script._dependency_inputs_changed() is False
|
||||
|
||||
def test_sync_script_compiles_and_compares_when_dependency_inputs_changed(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
sync_script = _load_sync_script()
|
||||
committed_requirements = tmp_path / ".github" / "security-audit-requirements.txt"
|
||||
generated_requirements = tmp_path / "generated-requirements.txt"
|
||||
committed_requirements.parent.mkdir()
|
||||
committed_requirements.write_text("pytest==1\n", encoding="utf-8")
|
||||
compile_commands = []
|
||||
|
||||
monkeypatch.setattr(sync_script, "REPO_ROOT", tmp_path)
|
||||
monkeypatch.setattr(sync_script, "COMMITTED_REQUIREMENTS", committed_requirements)
|
||||
monkeypatch.setenv("GENERATED_REQUIREMENTS", str(generated_requirements))
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
if command[0] == "git":
|
||||
return subprocess.CompletedProcess(command, 0, stdout="pyproject.toml\n", stderr="")
|
||||
compile_commands.append(command)
|
||||
assert kwargs["check"] is True
|
||||
generated_requirements.write_text("pytest==1\n", encoding="utf-8")
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
|
||||
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
|
||||
|
||||
assert sync_script.main() == 0
|
||||
assert len(compile_commands) == 1
|
||||
compile_command = " ".join(compile_commands[0])
|
||||
assert WORKFLOW_SYNC_COMPILE_TEST_EXTRA_DEPS in compile_command
|
||||
assert "--output-file" in compile_commands[0]
|
||||
assert str(generated_requirements) in compile_commands[0]
|
||||
|
||||
def test_sync_script_reports_missing_generated_requirements_env(
|
||||
self, monkeypatch, capsys
|
||||
):
|
||||
sync_script = _load_sync_script()
|
||||
monkeypatch.delenv("GENERATED_REQUIREMENTS", raising=False)
|
||||
|
||||
def fake_run(command, **_kwargs):
|
||||
if command[0] == "git":
|
||||
return subprocess.CompletedProcess(command, 0, stdout="pyproject.toml\n", stderr="")
|
||||
raise AssertionError("compile should not run without GENERATED_REQUIREMENTS")
|
||||
|
||||
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
|
||||
|
||||
assert sync_script.main() == 1
|
||||
assert "GENERATED_REQUIREMENTS must be set" in capsys.readouterr().err
|
||||
|
||||
def test_sync_script_fails_when_generated_requirements_differ(
|
||||
self, monkeypatch, tmp_path, capsys
|
||||
):
|
||||
sync_script = _load_sync_script()
|
||||
committed_requirements = tmp_path / ".github" / "security-audit-requirements.txt"
|
||||
generated_requirements = tmp_path / "generated-requirements.txt"
|
||||
committed_requirements.parent.mkdir()
|
||||
committed_requirements.write_text("pytest==1\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(sync_script, "REPO_ROOT", tmp_path)
|
||||
monkeypatch.setattr(sync_script, "COMMITTED_REQUIREMENTS", committed_requirements)
|
||||
monkeypatch.setenv("GENERATED_REQUIREMENTS", str(generated_requirements))
|
||||
|
||||
def fake_run(command, **_kwargs):
|
||||
if command[0] == "git":
|
||||
return subprocess.CompletedProcess(command, 0, stdout="pyproject.toml\n", stderr="")
|
||||
generated_requirements.write_text("pytest==2\n", encoding="utf-8")
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
|
||||
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
|
||||
|
||||
assert sync_script.main() == 1
|
||||
assert "Regenerate .github/security-audit-requirements.txt" in capsys.readouterr().err
|
||||
|
||||
def test_contributing_documents_security_commands(self):
|
||||
contributing_text = CONTRIBUTING.read_text(encoding="utf-8")
|
||||
|
||||
assert LOCAL_REFRESH_TEST_EXTRA_DEPS in contributing_text
|
||||
assert LOCAL_PIP_AUDIT in contributing_text
|
||||
assert "/tmp/" not in contributing_text
|
||||
assert "uv export" not in contributing_text
|
||||
@@ -1026,6 +1026,41 @@ class TestCommandStep:
|
||||
assert res_opt.status is StepStatus.FAILED
|
||||
assert "'options' must be a mapping" in (res_opt.error or "")
|
||||
|
||||
@pytest.mark.parametrize("bad", [["claude"], {"a": 1}, 5, True])
|
||||
def test_validate_rejects_non_string_integration_and_model(self, bad):
|
||||
"""A non-string 'integration'/'model' must be rejected at validation.
|
||||
|
||||
execute() passes 'integration' to get_integration(), which uses it as a
|
||||
dict key — an unhashable list/dict raises a raw TypeError there, even on
|
||||
a validated run — and feeds 'model' into the CLI argv. Mirrors the
|
||||
'command'/'input'/'options' type checks.
|
||||
"""
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
|
||||
step = CommandStep()
|
||||
errs = step.validate({"id": "c", "command": "/x", "integration": bad})
|
||||
assert any("'integration' must be a string" in e for e in errs), bad
|
||||
errs = step.validate({"id": "c", "command": "/x", "model": bad})
|
||||
assert any("'model' must be a string" in e for e in errs), bad
|
||||
|
||||
def test_validate_accepts_none_and_expression_integration_model(self):
|
||||
"""An explicit YAML-null (inherit default) or a '{{ ... }}' expression
|
||||
integration/model stays valid — only literal non-strings are rejected."""
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
|
||||
step = CommandStep()
|
||||
assert step.validate(
|
||||
{"id": "c", "command": "/x", "integration": None, "model": None}
|
||||
) == []
|
||||
assert step.validate(
|
||||
{
|
||||
"id": "c",
|
||||
"command": "/x",
|
||||
"integration": "{{ inputs.agent }}",
|
||||
"model": "{{ inputs.model }}",
|
||||
}
|
||||
) == []
|
||||
|
||||
def test_validate_rejects_non_string_command(self):
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
|
||||
@@ -1061,6 +1096,55 @@ class TestCommandStep:
|
||||
assert result.status is StepStatus.FAILED, bad
|
||||
assert "'command' must be a string" in (result.error or ""), bad
|
||||
|
||||
def test_execute_non_string_integration_fails_loudly(self):
|
||||
"""On an unvalidated run, an unhashable 'integration' would crash
|
||||
get_integration() (dict.get on a list) with a raw TypeError. execute()
|
||||
must fail the step with the contract error instead."""
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = CommandStep()
|
||||
res = step.execute(
|
||||
{"id": "c", "command": "speckit.specify", "integration": ["claude"]},
|
||||
StepContext(),
|
||||
)
|
||||
assert res.status is StepStatus.FAILED
|
||||
assert "'integration' must be a string" in (res.error or "")
|
||||
# non-string model likewise fails before build_exec_args
|
||||
res = step.execute(
|
||||
{"id": "c", "command": "speckit.specify", "integration": "claude", "model": ["m"]},
|
||||
StepContext(),
|
||||
)
|
||||
assert res.status is StepStatus.FAILED
|
||||
assert "'model' must be a string" in (res.error or "")
|
||||
|
||||
@pytest.mark.parametrize("falsey", [[], {}, 0, False])
|
||||
def test_execute_falsey_non_string_integration_fails_loudly(self, falsey):
|
||||
"""A *falsey* non-string ([], {}, 0, False) must fail the step, not be
|
||||
swallowed by an ``or``-fallback to the workflow default.
|
||||
|
||||
A ``config.get('integration') or context.default_integration`` coerces a
|
||||
falsey non-string to the default *before* the type guard runs, so with a
|
||||
configured default the step would silently dispatch using the wrong
|
||||
integration instead of surfacing the contract error. The default is set
|
||||
here so a regression dispatches rather than fails-not-possible."""
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = CommandStep()
|
||||
ctx = StepContext(default_integration="claude", default_model="sonnet")
|
||||
res = step.execute(
|
||||
{"id": "c", "command": "speckit.specify", "integration": falsey}, ctx
|
||||
)
|
||||
assert res.status is StepStatus.FAILED, falsey
|
||||
assert "'integration' must be a string" in (res.error or ""), falsey
|
||||
# a falsey non-string model likewise reaches the guard
|
||||
res = step.execute(
|
||||
{"id": "c", "command": "speckit.specify", "model": falsey}, ctx
|
||||
)
|
||||
assert res.status is StepStatus.FAILED, falsey
|
||||
assert "'model' must be a string" in (res.error or ""), falsey
|
||||
|
||||
def test_step_override_integration(self):
|
||||
from unittest.mock import patch
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
@@ -1448,6 +1532,82 @@ class TestPromptStep:
|
||||
)
|
||||
assert errors == []
|
||||
|
||||
@pytest.mark.parametrize("bad", [["claude"], {"a": 1}, 5, True])
|
||||
def test_validate_rejects_non_string_integration_and_model(self, bad):
|
||||
"""A non-string 'integration'/'model' must be rejected at validation.
|
||||
|
||||
execute() passes 'integration' to get_integration(), which uses it as a
|
||||
dict key — an unhashable list/dict raises a raw TypeError there, even on
|
||||
a validated run — and feeds 'model' into the CLI argv."""
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
|
||||
step = PromptStep()
|
||||
errs = step.validate({"id": "p", "prompt": "hi", "integration": bad})
|
||||
assert any("'integration' must be a string" in e for e in errs), bad
|
||||
errs = step.validate({"id": "p", "prompt": "hi", "model": bad})
|
||||
assert any("'model' must be a string" in e for e in errs), bad
|
||||
|
||||
def test_validate_accepts_none_and_expression_integration_model(self):
|
||||
"""An explicit YAML-null (inherit default) or a '{{ ... }}' expression
|
||||
integration/model stays valid — only literal non-strings are rejected."""
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
|
||||
step = PromptStep()
|
||||
assert step.validate(
|
||||
{"id": "p", "prompt": "hi", "integration": None, "model": None}
|
||||
) == []
|
||||
assert step.validate(
|
||||
{
|
||||
"id": "p",
|
||||
"prompt": "hi",
|
||||
"integration": "{{ inputs.agent }}",
|
||||
"model": "{{ inputs.model }}",
|
||||
}
|
||||
) == []
|
||||
|
||||
def test_execute_non_string_integration_fails_loudly(self):
|
||||
"""On an unvalidated run, an unhashable 'integration' would crash
|
||||
get_integration() (dict.get on a dict) with a raw TypeError. execute()
|
||||
must fail the step with the contract error instead."""
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = PromptStep()
|
||||
res = step.execute(
|
||||
{"id": "p", "prompt": "hi", "integration": {"a": 1}}, StepContext()
|
||||
)
|
||||
assert res.status is StepStatus.FAILED
|
||||
assert "'integration' must be a string" in (res.error or "")
|
||||
res = step.execute(
|
||||
{"id": "p", "prompt": "hi", "integration": "claude", "model": ["m"]},
|
||||
StepContext(),
|
||||
)
|
||||
assert res.status is StepStatus.FAILED
|
||||
assert "'model' must be a string" in (res.error or "")
|
||||
|
||||
@pytest.mark.parametrize("falsey", [[], {}, 0, False])
|
||||
def test_execute_falsey_non_string_integration_fails_loudly(self, falsey):
|
||||
"""A *falsey* non-string ([], {}, 0, False) must fail the step, not be
|
||||
swallowed by an ``or``-fallback to the workflow default.
|
||||
|
||||
A ``config.get('integration') or context.default_integration`` coerces a
|
||||
falsey non-string to the default *before* the type guard runs, so with a
|
||||
configured default the step would silently dispatch using the wrong
|
||||
integration instead of surfacing the contract error. The default is set
|
||||
here so a regression dispatches rather than fails-not-possible."""
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = PromptStep()
|
||||
ctx = StepContext(default_integration="claude", default_model="sonnet")
|
||||
res = step.execute({"id": "p", "prompt": "hi", "integration": falsey}, ctx)
|
||||
assert res.status is StepStatus.FAILED, falsey
|
||||
assert "'integration' must be a string" in (res.error or ""), falsey
|
||||
# a falsey non-string model likewise reaches the guard
|
||||
res = step.execute({"id": "p", "prompt": "hi", "model": falsey}, ctx)
|
||||
assert res.status is StepStatus.FAILED, falsey
|
||||
assert "'model' must be a string" in (res.error or ""), falsey
|
||||
|
||||
|
||||
class TestShellStep:
|
||||
"""Test the shell step type."""
|
||||
@@ -4349,6 +4509,108 @@ steps:
|
||||
assert WorkflowEngine._coerce_input("count", 5.0, {"type": "number"}) == 5
|
||||
assert WorkflowEngine._coerce_input("count", 3.5, {"type": "number"}) == 3.5
|
||||
|
||||
def test_coerce_input_rejects_non_list_enum_cleanly(self):
|
||||
"""A non-list ``enum`` (scalar or string) must raise a clean ValueError,
|
||||
not the raw ``TypeError`` from the ``value not in enum`` membership test.
|
||||
|
||||
A scalar (``enum: 5``) makes ``value not in 5`` raise
|
||||
``TypeError: argument of type 'int' is not iterable``. A bare string
|
||||
(``enum: "abc"``) is silently wrong instead — ``value in "abc"`` is a
|
||||
substring test, not enum membership — so it must be rejected too.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine
|
||||
|
||||
for bad_enum in (5, True, "abc", {"a": 1}):
|
||||
with pytest.raises(ValueError, match="invalid 'enum': must be a list"):
|
||||
WorkflowEngine._coerce_input(
|
||||
"scope", "x", {"type": "string", "enum": bad_enum}
|
||||
)
|
||||
# A valid list ``enum`` still works, and ``None`` means "no enum".
|
||||
assert (
|
||||
WorkflowEngine._coerce_input(
|
||||
"scope", "a", {"type": "string", "enum": ["a", "b"]}
|
||||
)
|
||||
== "a"
|
||||
)
|
||||
assert (
|
||||
WorkflowEngine._coerce_input("scope", "x", {"type": "string"}) == "x"
|
||||
)
|
||||
|
||||
def test_validate_workflow_rejects_non_list_enum(self):
|
||||
"""A non-list ``enum`` must be reported as an error, not crash
|
||||
``validate_workflow``. The membership test would raise ``TypeError``,
|
||||
which escapes its ``except ValueError`` and breaks the "return a list of
|
||||
errors, never raise" contract. This must surface even with no ``default``
|
||||
present (the coercion path that would otherwise catch it is only reached
|
||||
when a default exists).
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "bad-enum"
|
||||
name: "Bad Enum"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
scope:
|
||||
type: string
|
||||
enum: 5
|
||||
steps:
|
||||
- id: noop
|
||||
type: gate
|
||||
message: "noop"
|
||||
options: [approve]
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("invalid 'enum': must be a list" in e for e in errors), errors
|
||||
|
||||
def test_resolve_inputs_rejects_non_list_enum_at_runtime(self, project_dir):
|
||||
"""``execute()`` accepts unvalidated definitions, so a non-list ``enum``
|
||||
can reach ``_resolve_inputs`` at run time. It must fail with a clean
|
||||
ValueError rather than the raw ``TypeError`` from the membership test.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "runtime-bad-enum"
|
||||
name: "Runtime Bad Enum"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
scope:
|
||||
type: string
|
||||
enum: 5
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
with pytest.raises(ValueError, match="invalid 'enum': must be a list"):
|
||||
engine._resolve_inputs(definition, {"scope": "x"})
|
||||
|
||||
def test_non_list_enum_on_integration_auto_still_rejected(self, project_dir):
|
||||
"""The ``integration: auto`` sentinel strips a *list* ``enum`` before
|
||||
coercion (enum-membership is a runtime concern for ``auto``). A non-list
|
||||
``enum`` must NOT be silently stripped by that path — it is still an
|
||||
authoring error and must fail with the clean shape ValueError.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "auto-bad-enum"
|
||||
name: "Auto Bad Enum"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
enum: 5
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
with pytest.raises(ValueError, match="invalid 'enum': must be a list"):
|
||||
engine._resolve_inputs(definition, {})
|
||||
|
||||
def test_validate_workflow_rejects_infinite_default_for_number_type(self):
|
||||
"""``type: number`` with an infinite default (YAML ``.inf``) must be
|
||||
reported as an error, not raise. ``int(inf)`` raises OverflowError during
|
||||
@@ -6059,7 +6321,9 @@ class TestWorkflowCatalog:
|
||||
return "https://[::1"
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_http, "open_url", lambda url, timeout=30: _FakeResponse()
|
||||
auth_http,
|
||||
"open_url",
|
||||
lambda url, timeout=30, redirect_validator=None: _FakeResponse(),
|
||||
)
|
||||
|
||||
catalog = WorkflowCatalog(project_dir)
|
||||
@@ -6074,6 +6338,41 @@ class TestWorkflowCatalog:
|
||||
with pytest.raises(WorkflowCatalogError, match="malformed"):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
def test_fetch_validates_every_redirect_hop(self, project_dir, monkeypatch):
|
||||
"""A redirect_validator is passed to open_url and rejects a non-HTTPS
|
||||
INTERMEDIATE hop — closing the https -> http -> attacker-https chain a
|
||||
terminal-URL-only check would miss. Mirrors presets/extensions
|
||||
(#3523 / #3524)."""
|
||||
from specify_cli.workflows.catalog import (
|
||||
WorkflowCatalog,
|
||||
WorkflowCatalogEntry,
|
||||
WorkflowCatalogError,
|
||||
)
|
||||
from specify_cli.authentication import http as auth_http
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_open(url, timeout=30, redirect_validator=None):
|
||||
captured["rv"] = redirect_validator
|
||||
# Simulate the hop urllib validates before following the redirect.
|
||||
redirect_validator(
|
||||
"https://good.example/catalog.json", "http://evil.test/hop"
|
||||
)
|
||||
raise AssertionError("redirect_validator should have raised")
|
||||
|
||||
monkeypatch.setattr(auth_http, "open_url", fake_open)
|
||||
|
||||
catalog = WorkflowCatalog(project_dir)
|
||||
entry = WorkflowCatalogEntry(
|
||||
url="https://good.example/catalog.json",
|
||||
name="test",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
with pytest.raises(WorkflowCatalogError, match="HTTPS"):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
assert captured["rv"] is not None
|
||||
|
||||
def test_add_catalog(self, project_dir):
|
||||
from specify_cli.workflows.catalog import WorkflowCatalog
|
||||
|
||||
@@ -6618,7 +6917,9 @@ class TestStepCatalog:
|
||||
return "https://[not-an-ip]/x"
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_http, "open_url", lambda url, timeout=30: _FakeResponse()
|
||||
auth_http,
|
||||
"open_url",
|
||||
lambda url, timeout=30, redirect_validator=None: _FakeResponse(),
|
||||
)
|
||||
|
||||
catalog = StepCatalog(project_dir)
|
||||
@@ -6633,6 +6934,41 @@ class TestStepCatalog:
|
||||
with pytest.raises(StepCatalogError, match="malformed"):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
def test_fetch_validates_every_redirect_hop(self, project_dir, monkeypatch):
|
||||
"""A redirect_validator is passed to open_url and rejects a non-HTTPS
|
||||
INTERMEDIATE hop — closing the https -> http -> attacker-https chain a
|
||||
terminal-URL-only check would miss. Mirrors presets/extensions
|
||||
(#3523 / #3524)."""
|
||||
from specify_cli.workflows.catalog import (
|
||||
StepCatalog,
|
||||
StepCatalogEntry,
|
||||
StepCatalogError,
|
||||
)
|
||||
from specify_cli.authentication import http as auth_http
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_open(url, timeout=30, redirect_validator=None):
|
||||
captured["rv"] = redirect_validator
|
||||
# Simulate the hop urllib validates before following the redirect.
|
||||
redirect_validator(
|
||||
"https://good.example/steps.json", "http://evil.test/hop"
|
||||
)
|
||||
raise AssertionError("redirect_validator should have raised")
|
||||
|
||||
monkeypatch.setattr(auth_http, "open_url", fake_open)
|
||||
|
||||
catalog = StepCatalog(project_dir)
|
||||
entry = StepCatalogEntry(
|
||||
url="https://good.example/steps.json",
|
||||
name="test",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
with pytest.raises(StepCatalogError, match="HTTPS"):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
assert captured["rv"] is not None
|
||||
|
||||
def test_add_catalog(self, project_dir):
|
||||
from specify_cli.workflows.catalog import StepCatalog
|
||||
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-10T00:00:00Z",
|
||||
"updated_at": "2026-07-22T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.community.json",
|
||||
"workflows": {}
|
||||
"workflows": {
|
||||
"pipeline": {
|
||||
"id": "pipeline",
|
||||
"name": "Guided SDD Pipeline",
|
||||
"description": "Chains specify, clarify, plan, tasks, analyze, implement, and converge into one guided run with a single clarify gate and a post-implement convergence loop",
|
||||
"author": "domattioli",
|
||||
"version": "1.1.0",
|
||||
"url": "https://raw.githubusercontent.com/domattioli/spec-kit-workflow-pipeline/v1.1.0/workflow.yml",
|
||||
"repository": "https://github.com/domattioli/spec-kit-workflow-pipeline",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.11.2"
|
||||
},
|
||||
"tags": [
|
||||
"sdd",
|
||||
"pipeline",
|
||||
"automation"
|
||||
],
|
||||
"created_at": "2026-07-10T00:00:00Z",
|
||||
"updated_at": "2026-07-21T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user