Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions design/file-helper.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,29 @@ file-change observer snapshots it, then retain the before/after observer hooks
around the write. Downstream init failures therefore remain covered by the
existing lifecycle rollback, including prior policy and unrelated options.

Extension registry I/O also uses a deny-mode helper rooted at the project, so
`.specify`, `extensions`, and `.registry` are not trusted aliases. Registry
reads and writes reject linked storage regardless of operational symlink
policy. For a not-yet-created project, the nearest existing ancestor supplies
Comment on lines +91 to +94
the root without creating storage during reads. Writes validate the destination
before file-change observers run and preserve the existing non-atomic text-write
and metadata semantics. Missing or malformed registries retain their recovery
behavior, but parent policy is validated even for a missing or directory-valued
registry. Artifact inventory, template resolution, and event command resolution
fail closed on forbidden storage. CLI commands preserve their diagnostics and
JSON error envelopes; warning-only dependency and configuration probes preserve
their no-results fallback without reading through forbidden paths.
Generated event dispatchers validate the registry hierarchy using only the
standard library before delegating to an installed CLI or using their inline
fallback, so unavailable or stale packages cannot bypass link and reparse-point
rejection. A rejected hierarchy produces a diagnostic and exit status 2.
Bundled Bash, Python, and Windows PowerShell 5.1 template helpers reject linked
registry storage before reading or scanning the extension layer, including
dangling parent links. Higher-priority replacement layers retain their existing
short-circuit behavior.
This boundary has the portable helper's
concurrent-mutation limits described below.

```python
from specify_cli.file_helper import FileHelper

Expand Down Expand Up @@ -136,6 +159,7 @@ helpers for independent project/source/managed boundaries.

| Operation | Default deny | Explicit allow |
|---|---|---|
| Read metadata | Reject accessed parent or leaf links | Follow only existing contained regular-file or directory targets |
| Read file | Reject accessed parent or leaf links | Follow only existing contained regular-file targets |
| Create directory | Reject accessed links before creating parents | Follow contained directory links; reject dangling targets |
| Exclusively create file | Reject accessed links | Follow contained parents; reject an existing leaf without opening its target |
Expand All @@ -148,6 +172,8 @@ helpers for independent project/source/managed boundaries.
## API and failures

- `mkdir(path, parents=False, exist_ok=False)` creates directories.
- `stat(path)` reads file or directory metadata after applying the accessed-path
policy to the full hierarchy, including when a later component is missing.
- `read_bytes(path)` / `read_text(path, encoding="utf-8")` read regular files.
- `create_bytes(path, content, mode=0o644)` / `create_text(...)` exclusively
create files with existing parents. A no-follow `lstat` preflight rejects
Expand Down
5 changes: 5 additions & 0 deletions design/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,11 @@ metadata; they do not copy agent trees, user data, or the installed package
store. Content snapshots are created lazily before the first observed mutation,
with an aggregate limit of 128 MiB of file content and 4,096 entries per
transaction. Exceeding either limit fails before the affected write or removal.
Switch resolves the target's event configuration before removing the current
integration or its metadata. When events are enabled, this validates extension
registry storage even if a linked parent appears absent. A rejected hierarchy
leaves the current installation unchanged, including built-in-only switches
that do not use the external-adapter lifecycle journal.
Preset and extension package installation/replacement, configuration rescue
directories, and project extension configuration participate in the journal.
Package replacements keep one bounded directory snapshot rather than duplicate
Expand Down
57 changes: 40 additions & 17 deletions scripts/bash/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -427,8 +427,21 @@ _python3_command() {
fi
}

_validate_extension_registry_hierarchy() {
local ext_dir="$1"
local specify_dir="${ext_dir%/*}"
local path
for path in "$specify_dir" "$ext_dir" "$ext_dir/.registry"; do
if [ -L "$path" ]; then
echo "Error: refusing symlink in extension registry hierarchy: $path" >&2
return 1
fi
done
}

_sorted_extension_ids() {
local ext_dir="$1"
_validate_extension_registry_hierarchy "$ext_dir" || return 1
local -a python_cmd=()
local _python_cmd_line
while IFS= read -r _python_cmd_line; do
Expand All @@ -438,12 +451,23 @@ _sorted_extension_ids() {
local py_stderr sorted_ids
py_stderr=$(mktemp)
if sorted_ids=$(SPECKIT_EXTENSIONS="$ext_dir" "${python_cmd[@]}" -c "
import json, os, re, sys
import json, os, re, stat, sys
from pathlib import Path
root = Path(os.environ['SPECKIT_EXTENSIONS'])
registered = {}
registry = root / '.registry'
for path in (root.parent, root, registry):
try:
entry = path.lstat()
except FileNotFoundError:
break
except OSError as exc:
print('registry_invalid: ' + str(exc), file=sys.stderr)
sys.exit(1)
if stat.S_ISLNK(entry.st_mode) or getattr(entry, 'st_file_attributes', 0) & stat.FILE_ATTRIBUTE_REPARSE_POINT:
print('registry_invalid: refusing symlink or reparse point', file=sys.stderr)
sys.exit(1)
if os.path.lexists(registry):
if not registry.is_file():
print('registry_invalid: not a regular file', file=sys.stderr)
Expand Down Expand Up @@ -475,9 +499,10 @@ ranked = []
for ext_id, meta in registered.items():
if isinstance(ext_id, str) and re.fullmatch(r'[a-z0-9-]+', ext_id) and isinstance(meta, dict) and bool(meta.get('enabled', True)):
ranked.append((priority(meta.get('priority')), ext_id))
for path in root.iterdir():
if path.is_dir() and re.fullmatch(r'[a-z0-9-]+', path.name) and path.name not in registered:
ranked.append((10, path.name))
if root.is_dir():
for path in root.iterdir():
if path.is_dir() and re.fullmatch(r'[a-z0-9-]+', path.name) and path.name not in registered:
ranked.append((10, path.name))
for _, ext_id in sorted(ranked):
print(ext_id)
" 2>"$py_stderr"); then
Expand Down Expand Up @@ -593,19 +618,17 @@ except Exception:

# Priority 3: Extension-provided templates
local ext_dir="$repo_root/.specify/extensions"
if [ -d "$ext_dir" ]; then
local sorted_extensions=""
if ! sorted_extensions=$(_sorted_extension_ids "$ext_dir"); then
return 2
fi
while IFS= read -r extension_id; do
[ -n "$extension_id" ] || continue
local ext="$ext_dir/$extension_id"
local candidate="$ext/templates/${template_name}.md"
[ -f "$candidate" ] || candidate="$ext/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done <<< "$sorted_extensions"
local sorted_extensions=""
if ! sorted_extensions=$(_sorted_extension_ids "$ext_dir"); then
return 2
fi
while IFS= read -r extension_id; do
[ -n "$extension_id" ] || continue
local ext="$ext_dir/$extension_id"
local candidate="$ext/templates/${template_name}.md"
[ -f "$candidate" ] || candidate="$ext/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done <<< "$sorted_extensions"

# Priority 4: Core templates
local core="$base/${template_name}.md"
Expand Down Expand Up @@ -815,7 +838,7 @@ except Exception as exc:

# Priority 3: Extension-provided templates (always "replace")
local ext_dir="$repo_root/.specify/extensions"
if [ "$effective_base_found" = false ] && [ -d "$ext_dir" ]; then
if [ "$effective_base_found" = false ]; then
local sorted_extensions=""
if ! sorted_extensions=$(_sorted_extension_ids "$ext_dir"); then
return 2
Expand Down
31 changes: 23 additions & 8 deletions scripts/powershell/common.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -366,9 +366,26 @@ function Get-NormalizedPriority {
return $(if ($parsedPriority -ge 1) { $parsedPriority } else { 10 })
}

function Assert-ExtensionRegistryHierarchy {
param([Parameter(Mandatory=$true)][string]$ExtensionsDir)

$current = Split-Path -Parent (Split-Path -Parent $ExtensionsDir)
foreach ($component in @('.specify', 'extensions', '.registry')) {
$entry = Get-ChildItem -LiteralPath $current -Force -ErrorAction Stop |
Where-Object { $_.Name -eq $component } |
Select-Object -First 1
if ($null -eq $entry) { return }
if ($entry.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
throw "Refusing symlink or unsupported reparse point: $(Join-Path $current $component)"
}
$current = Join-Path $current $component
}
}

function Get-SortedExtensionIds {
param([Parameter(Mandatory=$true)][string]$ExtensionsDir)

Assert-ExtensionRegistryHierarchy -ExtensionsDir $ExtensionsDir
$registeredNames = @()
$ranked = @()
$registryFile = Join-Path $ExtensionsDir '.registry'
Expand Down Expand Up @@ -509,14 +526,12 @@ function Resolve-Template {

# Priority 3: Extension-provided templates
$extDir = Join-Path $RepoRoot '.specify/extensions'
if (Test-Path $extDir) {
foreach ($extensionId in Get-SortedExtensionIds -ExtensionsDir $extDir) {
$candidate = Join-Path $extDir "$extensionId/templates/$TemplateName.md"
if (-not (Test-Path $candidate)) {
$candidate = Join-Path $extDir "$extensionId/$TemplateName.md"
}
if (Test-Path $candidate) { return $candidate }
foreach ($extensionId in Get-SortedExtensionIds -ExtensionsDir $extDir) {
$candidate = Join-Path $extDir "$extensionId/templates/$TemplateName.md"
if (-not (Test-Path $candidate)) {
$candidate = Join-Path $extDir "$extensionId/$TemplateName.md"
}
if (Test-Path $candidate) { return $candidate }
}

# Priority 4: Core templates
Expand Down Expand Up @@ -729,7 +744,7 @@ except Exception as exc:

# Priority 3: Extension-provided templates (always "replace")
$extDir = Join-Path $RepoRoot '.specify/extensions'
if (-not $effectiveBaseFound -and (Test-Path $extDir)) {
if (-not $effectiveBaseFound) {
foreach ($extensionId in Get-SortedExtensionIds -ExtensionsDir $extDir) {
$candidate = Join-Path $extDir "$extensionId/templates/$TemplateName.md"
if (-not (Test-Path $candidate)) {
Expand Down
28 changes: 22 additions & 6 deletions scripts/python/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import json
import os
import re
import stat
import subprocess
import sys
from dataclasses import dataclass
Expand Down Expand Up @@ -246,7 +247,23 @@ def _sorted_preset_ids(presets_dir: Path) -> list[str]:
return []


def _validate_extension_registry_hierarchy(extensions_dir: Path) -> None:
for path in (extensions_dir.parent, extensions_dir, extensions_dir / ".registry"):
try:
entry = path.lstat()
except FileNotFoundError:
return
except OSError as exc:
raise TemplateResolutionError(f"Cannot inspect extension registry {path}: {exc}") from exc
if (
stat.S_ISLNK(entry.st_mode)
or getattr(entry, "st_file_attributes", 0) & stat.FILE_ATTRIBUTE_REPARSE_POINT
):
raise TemplateResolutionError(f"Refusing symlink or unsupported reparse point: {path}")


def _sorted_extension_ids(extensions_dir: Path) -> list[str]:
_validate_extension_registry_hierarchy(extensions_dir)
registry = extensions_dir / ".registry"
registered_ids: set[str] = set()
extensions: dict[object, object] = {}
Expand Down Expand Up @@ -340,12 +357,11 @@ def resolve_template(template_name: str, repo_root: Path) -> Path | None:
return candidate

ext_dir = repo_root / ".specify" / "extensions"
if ext_dir.is_dir():
for extension_id in _sorted_extension_ids(ext_dir):
ext = ext_dir / extension_id
candidate = _conventional_template(ext, template_name)
if candidate is not None:
return candidate
for extension_id in _sorted_extension_ids(ext_dir):
ext = ext_dir / extension_id
candidate = _conventional_template(ext, template_name)
if candidate is not None:
return candidate

core = base / f"{template_name}.md"
if core.is_file():
Expand Down
10 changes: 6 additions & 4 deletions src/specify_cli/artifacts/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,12 +210,14 @@ def _validate_project(project_root: Path) -> None:

def _validate_extension_registry(project_root: Path) -> None:
extensions_dir = project_root / ".specify" / "extensions"
if not extensions_dir.exists():
return

from ..extensions import ExtensionRegistry
from ..file_helper import FileHelperError

if ExtensionRegistry(extensions_dir).is_corrupt():
try:
registry = ExtensionRegistry(extensions_dir)
except FileHelperError as exc:
raise ArtifactResolutionError() from exc
if registry.is_corrupt():
raise ArtifactResolutionError()


Expand Down
38 changes: 33 additions & 5 deletions src/specify_cli/events/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import yaml
import typer

from ..file_helper import FileHelperError
from ..integrations._file_changes import unlink as _unlink_file, write_text as _write_text

if TYPE_CHECKING:
Expand Down Expand Up @@ -104,11 +105,28 @@
import re
import shlex
import shutil
import stat
import subprocess
import sys
from pathlib import Path, PurePosixPath, PureWindowsPath


def _validate_registry_hierarchy(project_root):
"""Reject registry links without depending on an installed CLI package."""
current = project_root
for component in (".specify", "extensions", ".registry"):
current = current / component
try:
entry = current.lstat()
except FileNotFoundError:
return
if (
stat.S_ISLNK(entry.st_mode)
or getattr(entry, "st_file_attributes", 0) & stat.FILE_ATTRIBUTE_REPARSE_POINT
):
raise ValueError(f"Refusing symlink or unsupported reparse point: {current}")


def _script_under_base(base, token, project_root):
"""Return token resolved under base, or None if it leaves the project."""
posix_path = PurePosixPath(token)
Expand Down Expand Up @@ -416,6 +434,11 @@ def main():
else:
payload = "{}"
project_root = Path(__file__).parent.parent.resolve()
try:
_validate_registry_hierarchy(project_root)
except (OSError, ValueError) as exc:
sys.stderr.write(f"Event command {command_name} error: {exc}\\n")
sys.exit(2)

# Preferred path: specify_cli is importable (durable install) — delegate to
# the full resolver, which also handles extension manifests whose file stem
Expand Down Expand Up @@ -559,6 +582,8 @@ def _find_command_template(command_name: str, project_root: Path) -> tuple[Path
candidate = exts_dir / ext_id / cmd["file"]
if candidate.exists():
return candidate, ext_id
except FileHelperError:
raise
except Exception:
# Fall through to the on-disk scan if the registry/manifests can't be
# read; event dispatch should degrade gracefully, not crash.
Expand Down Expand Up @@ -789,7 +814,11 @@ def resolve_and_run_event_command(
by the Claude Code hooks spec Gemini/Tabnine/Devin derive from). Only
used when *envelope* is ``hookSpecificOutput``.
"""
template_path, ext_id = _find_command_template(command_name, project_root)
try:
template_path, ext_id = _find_command_template(command_name, project_root)
except FileHelperError as exc:
sys.stderr.write(f"Event command {command_name} error: {exc}\n")
return 2
Comment thread
Copilot marked this conversation as resolved.
if not template_path:
logger.warning("Event command '%s' not found", command_name)
return 0
Expand Down Expand Up @@ -1060,13 +1089,13 @@ def _disabled_extension_ids(project_root: Path) -> set[str]:

exts_dir = project_root / ".specify" / "extensions"
disabled_ids: set[str] = set()
if not exts_dir.is_dir():
return disabled_ids
try:
registry = ExtensionRegistry(exts_dir)
for ext_id, meta in registry.list_by_priority(include_disabled=True):
if not isinstance(meta, dict) or not meta.get("enabled", True):
disabled_ids.add(ext_id)
except FileHelperError:
raise
except Exception:
pass
return disabled_ids
Expand Down Expand Up @@ -1096,11 +1125,10 @@ def collect_extension_events(project_root: Path) -> ResolvedEvents:

events: ResolvedEvents = {}
exts_dir = project_root / ".specify" / "extensions"
manager = ExtensionManager(project_root)
if not exts_dir.is_dir():
return events

manager = ExtensionManager(project_root)

# Build the set of explicitly-disabled extension IDs. Extensions not
# tracked in the registry are treated as enabled (backward compat).
disabled_ids = _disabled_extension_ids(project_root)
Expand Down
Loading
Loading