mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
* feat(cli): honor SPECIFY_INIT_DIR in the specify CLI project resolver The shell resolver honors SPECIFY_INIT_DIR (#2892), but the Python CLI did not: it resolved the project as Path.cwd() + a .specify/ check and never read the override. So setup-plan.sh respected it while `specify integration install` ignored it, and you still had to cd into the member project. Route project resolution through a shared _resolve_init_dir_override() that applies the shell resolver's validation rules (relative to cwd, must exist and contain .specify/, hard error, no fallback, same error strings). It's wired into _require_specify_project() — the chokepoint for every project-scoped subcommand (integration/extension/workflow/preset/...) — and the `workflow run <file>` standalone path, which re-applies its symlinked-.specify guard on the override branch too. init is unchanged: it creates .specify/, so the must-pre-exist rule doesn't apply. The resolver canonicalizes symlinks via Path.resolve() while the shell keeps the logical path; they agree for non-symlinked paths (documented in the resolver). Tests in tests/test_init_dir_cli.py mirror the strict cases from test_init_dir.py through the CLI; conftest now strips SPECIFY_* for the whole suite so a stray export can't perturb the now-env-reading resolver. Docs note the CLI applies the same rules. Discussion: github/spec-kit#2834 (Disclosure: I used an AI coding agent to audit the call sites and resolver, draft the change, and run an adversarial code review; reviewed by me.) * fix(cli): honor SPECIFY_INIT_DIR for bundle commands Assisted-by: Codex (model: GPT-5, autonomous) * fix(bundler): refuse symlinked .specify on the SPECIFY_INIT_DIR override path find_project_root refuses a symlinked .specify (following it could read/write outside the tree, and a test pins that), but the SPECIFY_INIT_DIR override added for bundle commands returned early and skipped that guard: _resolve_init_dir_override validates .specify with is_dir(), which follows symlinks. So `specify bundle` accepted via the override a layout the cwd path rejects. Re-check the override result with the same guard, plus a regression test. (Disclosure: found via an AI code review and fixed with an AI coding agent; reviewed by me.) * fix(cli): keep SPECIFY_INIT_DIR strict for bundles Treat an explicit symlinked SPECIFY_INIT_DIR project as a hard bundle error instead of returning no project, which could initialize the current directory. Align the docs with the actual unset resolver behavior. Assisted-by: Codex (model: GPT-5, autonomous) * docs(core): note symlinked .specify handling differs across CLI surfaces A symlinked .specify is followed by integration/extension/workflow (matching the shell resolver) but refused by bundle and workflow run <file> (write confinement). Document the asymmetry so it reads as intentional. (Disclosure: AI-assisted; reviewed by me.) * docs(core): reframe symlinked .specify note around the override invariant Per maintainer feedback on #3186: SPECIFY_INIT_DIR relocates where the project is, not how a surface treats symlinks. Each surface keeps its cwd-path stance (write surfaces refuse a symlinked .specify, read/config surfaces follow it), so the split is one policy relocated, not an inconsistency. * docs: address Copilot review on resolver docstrings - _project.py: the error messages "mirror" the shell wording rather than "match" it (the CLI renders a Rich `Error:` line, the shell a plain `ERROR:`). - find_project_root: document that honoring SPECIFY_INIT_DIR when start is None can raise typer.Exit / BundlerError, so the Path | None signature isn't surprising to direct callers. * docs(bundler): note require_project_root inherits the override raise behavior find_project_root can raise typer.Exit / BundlerError under the SPECIFY_INIT_DIR override (start=None); require_project_root inherits that, so document it alongside its own BundlerError-on-missing-project. * docs: clarify symlinked project root behavior Assisted-by: OpenAI Codex (model: GPT-5, autonomous) * Address SPECIFY_INIT_DIR review feedback Assisted-by: OpenAI Codex (model: GPT-5, autonomous) * Route workflow JSON errors to stderr Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
193 lines
6.5 KiB
Python
193 lines
6.5 KiB
Python
"""Security tests: path-traversal / symlink confinement (Constitution Principle V).
|
|
|
|
These assert the bundler refuses to read or write outside an allowed root, so a
|
|
malicious manifest or artifact path cannot escape the project/bundle directory.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from specify_cli.bundler import BundlerError
|
|
from specify_cli.bundler.lib.yamlio import ensure_within, is_safe_relpath
|
|
|
|
|
|
def test_ensure_within_allows_child(tmp_path: Path):
|
|
root = tmp_path / "bundle"
|
|
root.mkdir()
|
|
child = root / "sub" / "file.txt"
|
|
assert ensure_within(root, child) == child.resolve()
|
|
|
|
|
|
def test_ensure_within_rejects_parent_traversal(tmp_path: Path):
|
|
root = tmp_path / "bundle"
|
|
root.mkdir()
|
|
escape = root / ".." / "secret.txt"
|
|
with pytest.raises(BundlerError, match="escapes"):
|
|
ensure_within(root, escape)
|
|
|
|
|
|
def test_ensure_within_rejects_absolute_outside(tmp_path: Path):
|
|
root = tmp_path / "bundle"
|
|
root.mkdir()
|
|
with pytest.raises(BundlerError):
|
|
ensure_within(root, Path("/etc/passwd"))
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="symlink semantics differ on Windows")
|
|
def test_ensure_within_rejects_symlink_escape(tmp_path: Path):
|
|
root = tmp_path / "bundle"
|
|
root.mkdir()
|
|
outside = tmp_path / "outside.txt"
|
|
outside.write_text("secret", encoding="utf-8")
|
|
link = root / "link.txt"
|
|
link.symlink_to(outside)
|
|
with pytest.raises(BundlerError, match="escapes"):
|
|
ensure_within(root, link)
|
|
|
|
|
|
@pytest.mark.parametrize("rel,safe", [
|
|
("a/b.txt", True),
|
|
("./a.txt", True),
|
|
("../escape", False),
|
|
("a/../../escape", False),
|
|
("/abs", False),
|
|
("C:/abs", False),
|
|
("C:\\abs", False),
|
|
("\\\\server\\share", False),
|
|
("", False),
|
|
])
|
|
def test_is_safe_relpath(rel, safe):
|
|
assert is_safe_relpath(rel) is safe
|
|
|
|
|
|
def test_build_skips_symlinks(tmp_path: Path):
|
|
"""Packager must not follow symlinks out of the bundle dir."""
|
|
import yaml
|
|
|
|
from specify_cli.bundler.services.packager import build_bundle
|
|
from tests.bundler_helpers import valid_manifest_dict
|
|
|
|
bundle = tmp_path / "bundle"
|
|
bundle.mkdir()
|
|
(bundle / "bundle.yml").write_text(
|
|
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
|
)
|
|
(bundle / "README.md").write_text("# Demo", encoding="utf-8")
|
|
|
|
if os.name != "nt":
|
|
secret = tmp_path / "secret.txt"
|
|
secret.write_text("top secret", encoding="utf-8")
|
|
(bundle / "leak.txt").symlink_to(secret)
|
|
|
|
result = build_bundle(bundle, output_dir=tmp_path / "out")
|
|
import zipfile
|
|
|
|
with zipfile.ZipFile(result.artifact_path) as archive:
|
|
names = archive.namelist()
|
|
assert "leak.txt" not in names
|
|
assert "bundle.yml" in names
|
|
|
|
|
|
def test_load_records_refuses_symlinked_specify_escape(tmp_path: Path):
|
|
# Reading bundle-records.json must honour the same confinement as writes:
|
|
# a symlinked .specify pointing outside project_root is refused.
|
|
from specify_cli.bundler.models.records import load_records
|
|
|
|
project = tmp_path / "proj"
|
|
project.mkdir()
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
(outside / "bundle-records.json").write_text(
|
|
'{"schema_version": "1.0", "bundles": []}', encoding="utf-8"
|
|
)
|
|
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
|
|
|
with pytest.raises(BundlerError, match="escapes the allowed root"):
|
|
load_records(project)
|
|
|
|
|
|
def test_active_integration_refuses_symlinked_specify_escape(tmp_path: Path):
|
|
# Reading the integration marker must not follow a .specify symlink that
|
|
# resolves outside project_root; an escape is treated as "not determinable".
|
|
from specify_cli.bundler.lib.project import active_integration
|
|
|
|
project = tmp_path / "proj"
|
|
project.mkdir()
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
(outside / "integration.json").write_text(
|
|
'{"integration": "leaked"}', encoding="utf-8"
|
|
)
|
|
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
|
|
|
assert active_integration(project) is None
|
|
|
|
|
|
def test_read_catalog_config_refuses_symlinked_specify_escape(tmp_path: Path):
|
|
from specify_cli.bundler.commands_impl import catalog_config as cc
|
|
|
|
project = tmp_path / "proj"
|
|
project.mkdir()
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
(outside / "bundle-catalogs.yml").write_text(
|
|
"schema_version: '1.0'\ncatalogs: []\n", encoding="utf-8"
|
|
)
|
|
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
|
|
|
with pytest.raises(BundlerError, match="escapes the allowed root"):
|
|
cc._read(project)
|
|
|
|
|
|
def test_load_source_stack_refuses_symlinked_specify_dir(tmp_path: Path):
|
|
from specify_cli.bundler.models.catalog import load_source_stack
|
|
|
|
project = tmp_path / "project"
|
|
project.mkdir()
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
(outside / "bundle-catalogs.yml").write_text("catalogs: []\n", encoding="utf-8")
|
|
try:
|
|
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
|
except (OSError, NotImplementedError):
|
|
pytest.skip("symlinks not supported on this platform")
|
|
with pytest.raises(BundlerError, match="escapes the allowed root"):
|
|
load_source_stack(project)
|
|
|
|
|
|
def test_find_project_root_ignores_symlinked_specify(tmp_path: Path):
|
|
from specify_cli.bundler.lib.project import find_project_root
|
|
|
|
real = tmp_path / "real-specify"
|
|
real.mkdir()
|
|
project = tmp_path / "project"
|
|
project.mkdir()
|
|
try:
|
|
(project / ".specify").symlink_to(real, target_is_directory=True)
|
|
except (OSError, NotImplementedError):
|
|
pytest.skip("symlinks not supported on this platform")
|
|
# A symlinked .specify must not be accepted as a project root.
|
|
assert find_project_root(project) is None
|
|
|
|
|
|
def test_find_project_root_override_errors_on_symlinked_specify(tmp_path: Path, monkeypatch):
|
|
"""The SPECIFY_INIT_DIR override path refuses a symlinked .specify too,
|
|
matching the cwd loop path (regression: the override returned early and
|
|
skipped the symlink guard)."""
|
|
from specify_cli.bundler.lib.project import find_project_root
|
|
|
|
real = tmp_path / "real-specify"
|
|
real.mkdir()
|
|
project = tmp_path / "project"
|
|
project.mkdir()
|
|
try:
|
|
(project / ".specify").symlink_to(real, target_is_directory=True)
|
|
except (OSError, NotImplementedError):
|
|
pytest.skip("symlinks not supported on this platform")
|
|
monkeypatch.setenv("SPECIFY_INIT_DIR", str(project))
|
|
with pytest.raises(BundlerError, match="symlinked \\.specify"):
|
|
find_project_root(None)
|