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

python_package · https://github.com/github/spec-kit
LOW
20
/ 100 risk score
Snapshot · scanned Sep 30, 2026 · specify-cli@1.0.14.dev0 · engine 0.53.0 / ruleset 60

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.

raw = subprocess.run(
        [
            "git", "diff", "--name-only", "-z", "--no-renames",
            base_ref, head_ref, "--", EXTENSIONS_ROOT,
        ],
        check=True,
        capture_output=True,
    ).stdout
result = subprocess.run(
        ["git", "show", f"{ref}:{path}"], capture_output=True, text=True
    )
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)
proc = subprocess.run(
                [
                    self._python_exe,
                    "-c",
                    "import sys, json, yaml\n"
                    "def _default(value):\n"
                    f"    return {{'{_NON_N …
probe = subprocess.run(
            [
                python_override,
                "-c",
                "import sys, yaml\nraise SystemExit(sys.version_info.major != 3)",
            ],
            capture_output=True,
            time …
result = subprocess.run(
            command,
            capture_output=True,
            check=False,
            timeout=DOCKER_AGENT_CHECK_TIMEOUT,
        )
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,
        )

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(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")
return hashes.get(relative) == hashlib.sha256(path.read_bytes()).hexdigest()

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.dumps({"feature_directory": value}, separators=(",", ":")) + "\n").encode(
            "utf-8"
        )
    )
(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)
_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(stripped.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")

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.

parsed = urllib.parse.urlsplit(url)
url_path = urllib.request.url2pathname(urllib.parse.unquote(parsed.path))
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)
            )
return urllib.request.Request(url, headers=headers)
return urllib.request.Request(url, headers=headers)
return urllib.request.Request(url, headers=merged)
opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts, redirect_validator))
opener = urllib.request.build_opener(_StripAuthOnRedirect((), redirect_validator))
from urllib.request import url2pathname
return Path(url2pathname(f"//{netloc}{parsed.path}"))
return Path(url2pathname(parsed.path))

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 →