Is github/spec-kit safe?
- Python shell/command execution
- Possible command injection (shell + dynamic command)
- Python filesystem write/delete
specify-cli is an AI python_package analyzed by SkillTotal's deterministic static scanner. The scan found no malicious indicators, though 5 risky constructs are reported for review. It can: filesystem read, filesystem write, network egress and shell execution — capabilities are what the code can do, not a verdict on intent. Risk score 20/100 (low).
specify-cli 0.16.5.dev0
Automated static-analysis result. It can contain false positives and false negatives, and is not a claim about the intent of github/spec-kit's authors. Report a false positive.
Behavioral traits
How this component maps to the CSA agentic threat model. Descriptive — it never affects the risk score.
Findings (5)
The code builds an OS command out of values that can change at runtime, then runs it through a shell.
proc = subprocess.run( # noqa: S602 -- intentional shell=True (see NOTE above)
run_cmd,
shell=True,
capture_output=True,
text=True,
cwd=cwd,
en …Why it matters: If any of those values come from untrusted input, an attacker can run their own commands on the machine.
Fix: Pass arguments as a list without shell=True (e.g. subprocess.run(['git', 'checkout', branch])); never build a shell string from external input. If a shell is unavoidable, quote with shlex.quote.
The component can run operating-system commands or spawn processes.
merge_base = subprocess.run(
["git", "merge-base", base_ref, head_ref],
check=True,
cwd=REPO_ROOT,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True,
). …result = subprocess.run(
[
"git",
"diff",
"--name-only",
merge_base,
head_ref,
"--",
*DEPENDENCY_INPUTS, …subprocess.run(
[
"uv",
"pip",
"compile",
"pyproject.toml",
"--extra",
"test",
"--universal",
"--generate-hashes",
"--quiet" …probe = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
cwd=repo_root,
capture_output=True,
text=True,
)subprocess.run(
["git", *args], cwd=repo_root, capture_output=True, text=True
).returncodeuntracked = subprocess.run(
["git", "ls-files", "--others", "--exclude-standard"],
cwd=repo_root,
capture_output=True,
text=True,
).stdout.strip()result = subprocess.run(cmd, cwd=repo_root, capture_output=True, text=True)
subprocess.run(
["git", "-C", str(repo_root), "rev-parse", "--is-inside-work-tree"],
capture_output=True,
text=True,
).returncoderesult = subprocess.run(
["git", *args], cwd=repo_root, capture_output=True, text=True, env=env
)subprocess.run(
["git", "fetch", "--all", "--prune"],
cwd=repo_root,
capture_output=True,
text=True,
)create = subprocess.run(
["git", "checkout", "-q", "-b", branch_name],
cwd=repo_root,
capture_output=True,
text=True,
)switch = subprocess.run(
["git", "checkout", "-q", branch_name],
cwd=repo_root,
capture_output=True,
text=True, …result = subprocess.run(
["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"],
capture_output=True,
text=True,
)probe = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
cwd=repo_root,
capture_output=True,
text=True,
)result = subprocess.run(cmd, cwd=repo_root, capture_output=True, text=True)
result = subprocess.run(cmd, check=check_return, capture_output=True, text=True)
subprocess.run(cmd, check=check_return)
result = subprocess.run(
[uv_bin, "tool", "list"],
capture_output=True,
text=True,
timeout=_TIER3_REGISTRY_TIMEOUT_SECS,
env=_scrubbed_env() …result = subprocess.run(
[pipx_bin, "list", "--json"],
capture_output=True,
text=True,
timeout=_TIER3_REGISTRY_TIMEOUT_SECS,
env=_scrubbed_e …completed = subprocess.run(
plan.installer_argv,
shell=False,
check=False,
env=_scrubbed_env(),
timeout=timeout,
)result = subprocess.run(
[specify_bin, "--version"],
shell=False,
check=False,
capture_output=True,
text=True,
timeout=_VERIFY_TIMEOUT_SECS,
env=_scrubbed_e …result = subprocess.run( # noqa: S603, S607
[
az,
"account",
"get-access-token",
"--resource",
_ADO_RESOURCE_ID, …result = subprocess.run(
argv,
input=payload,
capture_output=True,
text=True,
timeout=timeout,
cwd=str(project_root),
)result = subprocess.run(
exec_args,
text=True,
cwd=cwd,
)result = subprocess.run(
exec_args,
capture_output=True,
text=True,
cwd=cwd,
timeout=timeout,
)Why it matters: Powerful and often legitimate — confirm the commands aren't built from untrusted input.
Fix: Confirm the command and its arguments are fully controlled and not derived from untrusted input; avoid shell=True.
The component reads files from disk.
generated_requirements.write_bytes(COMMITTED_REQUIREMENTS.read_bytes())
committed = COMMITTED_REQUIREMENTS.read_text(encoding="utf-8")
generated = generated_requirements.read_text(encoding="utf-8")
with open(
f"{project_root}/.specify/init-options.json", "r", encoding="utf-8"
) as fh:with open(defaults_path, "r", encoding="utf-8") as fh:
with open(feature_json, "r", encoding="utf-8") as fh:
with open(ctx_path, "r", encoding="utf-8-sig") as fh:
with open(ext_config, "r", encoding="utf-8") as fh:
content = config_file.read_text(encoding="utf-8")
lines = config_file.read_text(encoding="utf-8").splitlines()
lines = config_file.read_text(encoding="utf-8").splitlines()
data = json.loads(feature_json.read_text(encoding="utf-8"))
data = json.loads(registry.read_text(encoding="utf-8"))
data = json.loads(registry.read_text(encoding="utf-8"))
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
content = layers[-1][0].read_bytes().decode("utf-8")layer_content = path.read_bytes().decode("utf-8")state = json.loads(integration_json.read_text(encoding="utf-8"))
with open(pyproject_path, "rb") as f:
payload = json.loads(path.read_text(encoding="utf-8"))
with open(sub_item, 'r', encoding='utf-8') as f:
with open(existing_path, 'r', encoding='utf-8') as f:
payload = dist.read_text("direct_url.json")content = source_file.read_text(encoding="utf-8")
raw = json.loads(config_path.read_text(encoding="utf-8"))
Why it matters: Usually legitimate, but worth confirming it can't be steered into reading sensitive files.
Fix: Confirm which files are read and that paths cannot be influenced by untrusted input to reach sensitive locations.
The component writes or deletes files on disk.
generated_requirements.write_bytes(COMMITTED_REQUIREMENTS.read_bytes())
with open(ctx_path, "wb") as fh:
(specify_dir / "feature.json").write_bytes(
_json_dump({"feature_directory": value}).encode("utf-8")
)spec_file.write_bytes(template_content.encode("utf-8"))paths.impl_plan.write_bytes(template_content.encode("utf-8"))dest.write_text(
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
encoding="utf-8",
)shutil.copy2(sub_item, dest_file)
shutil.copy2(sub_item, dest_file)
dest_file.write_text(content, encoding="utf-8")
cache_file.write_text(content, encoding="utf-8")
dest_file.write_text(content, encoding="utf-8")
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8")shutil.copytree(step_dir, backup_dir)
shutil.copytree(backup_dir, step_dir, dirs_exist_ok=True)
shutil.rmtree(backup_dir.parent, ignore_errors=True)
artifact.write_bytes(raw)
_shutil.copy2(
bundled_wf / "workflow.yml",
dest_wf / "workflow.yml",
)shutil.rmtree(project_path)
dispatcher_path.write_text(_EVENTS_DISPATCHER_TEMPLATE, encoding="utf-8")
plugin_path.write_text(
_build_opencode_plugin(filtered, canonical_to_native),
encoding="utf-8",
)dst.write_text(existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8")
dst.write_text(existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8")
dst.write_text(cleaned, encoding="utf-8")
dst.write_text(cleaned, encoding="utf-8")
dst.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
Why it matters: Usually legitimate, but worth confirming the paths can't be controlled by untrusted input.
Fix: Confirm which files are written/deleted and that paths cannot be influenced by untrusted input.
The component makes outbound network requests.
from urllib.parse import ParseResult, urlparse
path = urlparse(name).path.lower()
parsed = urlparse(url)
import urllib.request
from urllib.parse import quote, unquote, urlparse
parsed = urlparse(url)
return urllib.request.Request(url, headers=headers)
import urllib.error
parsed = urlparse(download_url)
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]encoded_tag = quote(tag, safe="")
import urllib.error
import urllib.parse
import urllib.request
parsed = urllib.parse.urlsplit(url)
url_path = urllib.request.url2pathname(urllib.parse.unquote(parsed.path))
import urllib.error
import urllib.request
from urllib.parse import urlencode
body = urlencode({
"grant_type": "client_credentials",
"client_id": entry.client_id,
"client_secret": client_secret,
"scope": f"{_ADO_RESOURCE_ID}/.default",
}).encode("utf-8")req = urllib.request.Request(
url,
data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)raise urllib.error.URLError(
f"Azure AD token request must not be redirected to {new_url}"
)opener = urllib.request.build_opener(
_StripAuthOnRedirect((), reject_token_redirect)
)from urllib.parse import urlparse
hostname = (urlparse(url).hostname or "").lower()
Why it matters: Usually legitimate, but confirm the destinations are expected and no sensitive data leaves.
Fix: Confirm the destination hosts are expected and that no sensitive data is sent off-host.
Check your own component
Run the same evidence-backed scan on any MCP server, agent skill, or package.
Scan your own componentHow we determine this: deterministic static analysis (regex + AST), evidence-anchored, no code execution. Methodology →