CodeVetter · Repo Unpack · Risk map
github/spec-kit
The highest-risk surface is remote-code paths the CLI intentionally supports: project-local custom workflow steps are imported and executed, URL-based extension/bundle installs download and extract archives, and generated event dispatchers + workflow `shell`/`command` steps run subprocesses with user privileges. These are hardened with HTTPS-only rules, byte caps, zip-slip defenses, SHA-256 pins, trust prompts, and path confinement — but the security posture is defense-in-depth around inherently dangerous features. Secondary risks: three giant modules concentrate blast radius, compatibility shims create fragile monkeypatch coupling, and a few silent-fallback paths can hide breakage.
- `load_custom_steps` dynamically imports and executes `__init__.py` from every package under `.specify/workflows/steps/` — arbitrary code execution by design for community step types. Mitigations: symlink rejection on every path component, sanitized synthetic module names, sys.modules cleanup on failure — but any file dropped into that directory runs with user privileges.
- Workflow `shell` and `command` steps run subprocesses with the user's full privileges; the engine explicitly documents that `requires:` (and any hypothetical `permissions:` key — which is rejected by validation) is advisory and 'NOT a security boundary'.
- URL installs (`--extension <url>`, `extension add --from`, catalog downloads) fetch remote archives: hardened by `is_https_or_localhost_http`, `is_safe_download_redirect`, `read_response_limited` byte caps, `safe_extract_archive` (entry count/size/path limits, Windows reserved names), and optional `sha256` catalog pins — but a missing pin means integrity rests on HTTPS alone.
- The generated `.specify/events.py` dispatcher runs event-mapped scripts inside agent hooks; `EVENT_SCRIPT_PATH_CONFINEMENT = True` exists because an older stale dispatcher would otherwise run unconfined script tokens — i.e. this code path was previously exploitable and is now gated by a version sentinel.
- Three files concentrate enormous blast radius: `presets/__init__.py` (6259 LOC), `extensions/__init__.py` (5481 LOC), `workflows/_commands.py` (3901 LOC) — each mixes domain logic, validation, and CLI plumbing, so a bad edit can break an entire command group.
- Fragile test-coupling pattern: `_commands.py` modules keep thin forwarders that re-fetch helpers (`_require_specify_project`, `load_init_options`, …) from the package root at call time purely so existing monkeypatch paths keep working; renaming or moving the root helpers silently breaks tests and commands.
- Subprocess policy is enforced by lint rather than review: ruff `extend-select` enables S602/S604/S605 so any `shell=True`/`os.system`/`popen2` reintroduction needs an explicit noqa, but other subprocess invocations (agent CLIs, scripts) are unrestricted list-form exec.
- `specify init --here --force` (and `--force` on existing dirs) merges/overwrites into non-empty directories; the confirmation/EOF handling is intricate (Abort vs EOFError distinction) and a scripting mistake there writes into a user's existing project.
- Deprecated hidden flags (`--skip-tls`, `--github-token`, `--offline`, `--debug`) are accepted as silent no-ops — scripts relying on the old behavior get no warning that TLS verification or offline mode semantics changed.
- Custom step loading swallows every exception (`except Exception # noqa: BLE001` → `continue`), so a broken or maliciously-aborting step package fails invisibly; same silent-skip posture applies to malformed `step.yml`.
- Credential handling: `~/.specify/auth.json` maps hosts to providers and `_github_http.py` attaches GITHUB_TOKEN/GH_TOKEN only to a fixed host allowlist (github.com, api.github.com, raw.githubusercontent.com, codeload.github.com) — correct, but the file is user-managed with no permission checks enforced in the registry layer.
- `_load_core_command_names` falls back to a hardcoded `_FALLBACK_CORE_COMMAND_NAMES` frozenset when neither core_pack nor the repo templates are readable; if the bundled template set drifts from the fallback, extension command-name validation silently uses stale data (a previous `.parent`-counting bug already broke this once, per the docstring).