Is github/spec-kit safe?
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.12.5.dev0
- Python shell/command execution
- Possible command injection (shell + dynamic command)
- Python filesystem read
No malicious indicators found by static analysis.
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.
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,
ti …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.
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(
exec_args,
text=True,
cwd=cwd,
)result = subprocess.run(
exec_args,
capture_output=True,
text=True,
cwd=cwd,
timeout=timeout,
)result = subprocess.run(
cli_args,
text=True,
cwd=cwd,
)result = subprocess.run(
cli_args,
capture_output=True,
text=True,
cwd=cwd,
timeout=timeout,
)result = subprocess.run(
exec_args,
text=True,
cwd=str(project_root),
)proc = subprocess.run( # noqa: S602 -- intentional shell=True (see NOTE above)
run_cmd,
shell=True,
capture_output=True,
text=True,
cwd=cwd,
ti …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.
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"))
return loads_json(path.read_text(encoding="utf-8"), origin=str(path))
return loads_json(path.read_text(encoding="utf-8"), origin=str(path))
archive.writestr(info, file_path.read_bytes())
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
with open(path, "r", encoding="utf-8") as f:
with open(self.path, "rb") as f:
with open(self.registry_path, "r") as f:
raw = ignore_file.read_text(encoding="utf-8")
content = source_file.read_text(encoding="utf-8")
raw = skill_md.read_text(encoding="utf-8")
raw = skill_md.read_text(encoding="utf-8")
metadata = json.loads(cache_meta_file.read_text(encoding="utf-8"))
cached_data = json.loads(cache_file.read_text(encoding="utf-8"))
metadata = json.loads(self.cache_metadata_file.read_text(encoding="utf-8"))
cached_data = json.loads(self.cache_file.read_text(encoding="utf-8"))
return yaml.safe_load(file_path.read_text(encoding="utf-8")) or {}result = yaml.safe_load(self.config_file.read_text(encoding="utf-8"))
config = yaml.safe_load(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.
dest.write_text(
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False),
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")artifact.write_bytes(raw)
shutil.copy2(template_constitution, memory_constitution)
_shutil.copy2(
bundled_wf / "workflow.yml",
dest_wf / "workflow.yml",
)shutil.rmtree(project_path)
with open(self.registry_path, "w") as f:
cache_file.write_text(skill_content, encoding="utf-8")
skill_file.write_text(skill_content, encoding="utf-8")
skill_file.write_text(skill_content, encoding="utf-8")
shutil.rmtree(skill_subdir)
shutil.rmtree(skill_subdir)
shutil.rmtree(backup_config_dir)
shutil.rmtree(dest_dir)
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
shutil.copy2(cfg_file, dest_dir / cfg_file.name)
shutil.rmtree(backup_config_dir)
shutil.rmtree(child)
shutil.copy2(config_file, backup_path)
shutil.rmtree(extension_dir)
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.
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"},
)with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
from urllib.parse import urlparse
hostname = (urlparse(url).hostname or "").lower()
import urllib.error
import urllib.request
from urllib.parse import urlparse
old_scheme = urlparse(req.full_url).scheme
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 componentOr get notified if this component's risk changes:
How we determine this: deterministic static analysis (regex + AST), evidence-anchored, no code execution. Methodology →