SkillTotal

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_package · https://github.com/github/spec-kit
LOW
20
/ 100 risk score
Snapshot · scanned Jul 3, 2026 · specify-cli@0.12.5.dev0 · engine 0.24.0 / ruleset 25
No malicious indicators - review capabilities before installing
Notable — review in context (capabilities are not malware):
  • 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.

Capabilities — what this component can do (not a risk score):
filesystem readfilesystem writenetwork egressshell execution

Findings (5)

HIGHPossible command injection (shell + dynamic command)ST-CMDI-PY

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.

HIGHPython shell/command executionST-SHELL-PY

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.

MEDIUMPython filesystem readST-FS-PY-READ

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.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.

MEDIUMPython filesystem write/deleteST-FS-PY-WRITE

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")
shutil.copy2(template_constitution, memory_constitution)
_shutil.copy2(
                                bundled_wf / "workflow.yml",
                                dest_wf / "workflow.yml",
                            )
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.copytree(source_dir, dest_dir, ignore=ignore_fn)
shutil.copy2(cfg_file, dest_dir / cfg_file.name)
shutil.copy2(config_file, backup_path)

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.

MEDIUMPython network egressST-NET-PY

The component makes outbound network requests.

from urllib.parse import quote, unquote, urlparse
return urllib.request.Request(url, headers=headers)
parsed = urlparse(download_url)
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]
encoded_tag = quote(tag, safe="")
parsed = urllib.parse.urlsplit(url)
url_path = urllib.request.url2pathname(urllib.parse.unquote(parsed.path))
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
hostname = (urlparse(url).hostname or "").lower()
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 component

Or get notified if this component's risk changes:

How we determine this: deterministic static analysis (regex + AST), evidence-anchored, no code execution. Methodology →