SkillTotal

Is github/spec-kit safe?

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

python_package · https://github.com/github/spec-kit
LOW
20
/ 100 risk score
Snapshot · scanned Aug 15, 2026 · specify-cli@0.16.5.dev0 · engine 0.38.1 / ruleset 42

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

Behavioral traits

How this component maps to the CSA agentic threat model. Descriptive — it never affects the risk score.

Execution authority
Tool Access Control / Direct Tool Access
Filesystem reach
Tool Execution Context
Network egress
Interaction & Communication / Direct Communication

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

HIGHPython shell/command executionST-SHELL-PY

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
            ).returncode
untracked = 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,
        ).returncode
result = 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.

MEDIUMPython filesystem readST-FS-PY-READ

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.

MEDIUMPython filesystem write/deleteST-FS-PY-WRITE

The component writes or deletes files on disk.

generated_requirements.write_bytes(COMMITTED_REQUIREMENTS.read_bytes())
(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(backup_dir, step_dir, dirs_exist_ok=True)
shutil.rmtree(backup_dir.parent, ignore_errors=True)
_shutil.copy2(
                                bundled_wf / "workflow.yml",
                                dest_wf / "workflow.yml",
                            )
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.

MEDIUMPython network egressST-NET-PY

The component makes outbound network requests.

from urllib.parse import ParseResult, urlparse
path = urlparse(name).path.lower()
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"},
        )
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)
            )
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 component

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