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
79 changes: 71 additions & 8 deletions design/file-helper.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

`specify_cli.file_helper.FileHelper` owns filesystem operations under one
explicitly established root. Project-command boundaries now validate persisted
policy, and init-options bootstrap I/O uses the helper. Other existing command
filesystem operations are **not yet migrated**: loading policy alone does not
enforce symlink behavior on those operations. Shared infrastructure, registration,
managed storage, catalogs, scripts and runtime operations retain their existing
behavior until their dependent migration stages.
policy, and init-options bootstrap I/O uses the helper. Shared infrastructure installation and template refresh now execute their
project-side operations through an explicitly handed-off helper, including their
shared manifest reads, hashes and saves. Other existing command filesystem
operations are **not yet migrated**: loading policy alone does not enforce
symlink behavior on registration, managed storage, catalogs, script execution or
runtime operations.

Only `specify init --allow-symlinks` exposes the setting; init defaults to deny
and persists the literal JSON boolean `allow_symlinks` in
Expand Down Expand Up @@ -149,6 +150,13 @@ helpers for independent project/source/managed boundaries.

- `mkdir(path, parents=False, exist_ok=False)` creates directories.
- `read_bytes(path)` / `read_text(path, encoding="utf-8")` read regular files.
- `stat(path, follow_symlinks=True, missing_ok=False)` returns policy-checked
metadata. `missing_ok=True` returns `None` only for genuinely absent entries,
including beneath missing parents. Dangling link targets, invalid parents,
permissions, cycles and containment failures still raise. With
`follow_symlinks=False`, allow mode inspects a leaf link without following its
target; deny mode still rejects it, and linked parents and unsupported reparse
entries remain checked.
- `create_bytes(path, content, mode=0o644)` / `create_text(...)` exclusively
create files with existing parents. A no-follow `lstat` preflight rejects
existing leaves, including dangling links, before opening the destination.
Expand Down Expand Up @@ -195,6 +203,60 @@ delete includes the entire selected tree; there is no exclusion filter in this
foundation. Future operations with exclusions must inspect only included
entries, not reject a whole project for excluded/unrelated links.

## Shared infrastructure ownership

FileHelper owns symlink-aware filesystem CRUD and metadata access, not project
configuration, file ownership, overwrite decisions or transaction/rollback
policy. Callers select the paths they actually need to access; the helper
enforces symlink policy on those operations, not on unrelated project entries.

`install_shared_infra` and `refresh_shared_templates` receive an explicit
`files: FileHelper` from init and integration lifecycle callers. Compatibility
callers that omit it resolve the selected project's current policy afresh.
Init supplies a helper using its current requested flag before final persistence,
not an earlier stored value. Config bootstrap remains deny-only.

The helper executes project metadata probes, directory creation, reads/hashes,
atomic writes and stale managed-script deletion for `.specify/scripts/<variant>/`,
`.specify/templates/`, `.specify/.gitignore`, and
`.specify/integrations/speckit.manifest.json`. Source/bundled asset operations,
archive verification, locks and other manifest consumers are unchanged.
`IntegrationManifest` has an optional helper mode for these scoped load/save,
record-existing, modification-check and lexical key-bookkeeping operations;
legacy consumers retain their existing behavior.

Accessed denied links now abort instead of warning and succeeding with skipped
destinations, including with `--force`. Allow mode preserves contained links
while retaining missing-only, managed/unmodified refresh, recovered/customized
preservation and force-overwrite decisions. Policy violations never become a
recovered-file or replacement-manifest success. Malformed manifest content still
uses the existing warned recovery behavior. Manifest keys retain their lexical
project-relative identity even when file bytes are read through allowed aliases.
Stale leaf links remain customizations, including dangling/external-target links:
they are never hash-promoted into managed files or deleted by stale cleanup.
Eligible stale regular files reached through contained linked parents can be
removed through the helper without deleting their parent links.

`mkdir`, `write_bytes`/`write_text`, and `delete` accept an optional explicit
`Callable[[Path, bool, bool], None]` mutation observer (path, before, removal).
The helper validates before callbacks, reports the actual contained target for
writes and directory creation, and reports the nonfollow entry for deletion.
After callbacks occur only following successful mutations; callback failures
propagate and atomic-write temporary cleanup remains active. `mkdir` reports
only genuinely new directories in parent order, never existing directory trees.
The integration lifecycle journals file/package mutations through its existing
observer and tracks directory creation separately without claiming contents.
Rollback restores observed target bytes/modes while preserving links, then
removes only identified empty new directories; unrelated contents survive.
Shared scope census retains lexical link entries and does not traverse linked
scope roots or descendants. Other adapter/package/registry/lock restrictions
are unchanged; operational permission does not waive those boundaries.
In particular, external integration transactions still reject a linked
Comment on lines +247 to +254

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in commit 09ddb195266f9f237f11e577f7ab357cab9adf51. The integration lifecycle document now explicitly distinguishes FileHelper-owned shared infrastructure from the other host write helpers: shared operations honor the selected deny/allow setting, while adapter-package/registry validation, configuration bootstrap, legacy manifest consumers, and uninstall keep their existing restrictions.

This documents the caller behavior already implemented in this PR; it does not change FileHelper's symlink-aware CRUD contract or add ownership, overwrite, rollback, or concurrency responsibilities. No further code change is needed for this finding.

AI disclosure: reply posted on behalf of @mnriem by GitHub Copilot using GPT-6.1 Sol (gpt-6.1-sol), autonomous execution under the requested scope, with the runtime reasoning setting not explicitly selected. The agent verified the existing documentation correction and authored this reply; no human line-by-line review is claimed.

`.specify/integrations` scope root because adapter-package and registry storage
are outside this migration. Shared manifest leaf links are supported; standalone
shared operations also support contained linked manifest parents. Integration
uninstall retains its existing scope validation.

## Safety limits and migration

Preflight detects static policy failures before recursive deletion or parent
Expand All @@ -207,9 +269,10 @@ relative traversal or platform-specific mechanisms are future work, not a
claim of this API. The helper does not constrain subprocesses or third-party
code. Exclusive creation uses `O_EXCL` and `O_NOFOLLOW` where available.

Existing safe-write mechanisms informed this helper, but existing shared
infrastructure warning/skip behavior and development-mode link creation are
unchanged. Migrate one owned operation boundary at a time, carrying original
Existing safe-write mechanisms informed this helper; shared installation and
refresh now enforce its policy instead of warning and skipping denied links.
Development-mode registration and unmigrated private safety helpers retain their
existing behavior. Migrate one owned operation boundary at a time, carrying original
paths and an explicit root/policy into the helper before any early resolution.
Linked command directories and development-mode registration remain dependent
stages. Non-atomic configuration writes preserve legacy metadata semantics;
Expand Down
16 changes: 15 additions & 1 deletion design/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,21 @@ Host settings merges, native event updates/removal, legacy migrations, and
extension/preset rendering caches participate in the journal without becoming
uninstall-owned files. Existing built-in home-scoped destinations are journaled
only for participating built-ins; external adapters remain project-local.
Host write helpers reject symlinked destinations and ancestors before creating
FileHelper-owned shared infrastructure follows the selected symlink setting
for `.specify/scripts/`, `.specify/templates/`, `.specify/.gitignore`, and the
shared `speckit.manifest.json` I/O. Deny rejects accessed links; allow uses
supported contained targets while preserving link identity. Init supplies its
current requested setting; install/upgrade/switch read persisted project policy.
FileHelper performs filesystem operations, not ownership or overwrite decisions.
Shared callers retain their existing customization/recovery rules, and allow
mode preserves stale leaf links without following their targets. Helper-mode
manifest policy failures propagate rather than becoming tolerant recovery or preserved-file
success. This does not relax adapter-package/registry scope validation, config
bootstrap, legacy manifest consumers, or uninstall.
External adapter transactions still reject linked `.specify/integrations` scope
roots; shared manifest leaf links are supported.

Other host write helpers reject symlinked destinations and ancestors before creating
directories or writing. Manifest recording validates lexical paths before
resolution, including project-local and dangling symlinks, so resolution cannot
hide a link to an unrelated project file. Removing an owned leaf symlink unlinks
Expand Down
15 changes: 13 additions & 2 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import os
import sys
from pathlib import Path
from .file_helper import FileHelper

import typer
from rich.align import Align
Expand Down Expand Up @@ -117,8 +118,11 @@ def _refresh_shared_templates(
invoke_separator: str,
invoke_prefix: str = "/",
force: bool = False,
files: FileHelper | None = None,
) -> None:
"""Refresh default-sensitive shared templates without touching scripts."""
from ._file_policy import project_files

_refresh_shared_templates_impl(
project_path,
version=get_speckit_version(),
Expand All @@ -128,6 +132,7 @@ def _refresh_shared_templates(
invoke_separator=invoke_separator,
invoke_prefix=invoke_prefix,
force=force,
files=files if files is not None else project_files(project_path),
)


Expand All @@ -140,6 +145,7 @@ def _install_shared_infra(
invoke_prefix: str = "/",
refresh_managed: bool = False,
refresh_hint: str | None = None,
files: FileHelper | None = None,
) -> bool:
"""Install shared infrastructure files into *project_path*.

Expand All @@ -154,8 +160,8 @@ def _install_shared_infra(

Overwrite policy:

* ``force=True`` — overwrite every existing file (still skips symlinks
to avoid following links outside the project root).
* ``force=True`` — overwrite every existing regular file permitted by
the operation's FileHelper policy, preserving allowed links.
* ``refresh_managed=True`` — overwrite only files whose on-disk hash
still matches the previously recorded manifest hash (i.e. unmodified
files installed by spec-kit). Files with diverging hashes are
Expand All @@ -172,6 +178,8 @@ def _install_shared_infra(

Returns ``True`` on success.
"""
from ._file_policy import project_files

return _install_shared_infra_impl(
project_path,
script_type,
Expand All @@ -184,6 +192,7 @@ def _install_shared_infra(
invoke_prefix=invoke_prefix,
refresh_managed=refresh_managed,
refresh_hint=refresh_hint,
files=files if files is not None else project_files(project_path),
)


Expand All @@ -196,6 +205,7 @@ def _install_shared_infra_or_exit(
invoke_prefix: str = "/",
refresh_managed: bool = False,
refresh_hint: str | None = None,
files: FileHelper | None = None,
) -> bool:
try:
return _install_shared_infra(
Expand All @@ -207,6 +217,7 @@ def _install_shared_infra_or_exit(
invoke_prefix=invoke_prefix,
refresh_managed=refresh_managed,
refresh_hint=refresh_hint,
files=files,
)
except (ValueError, OSError) as exc:
console.print(f"[red]Error:[/red] Failed to install shared infrastructure: {exc}")
Expand Down
2 changes: 2 additions & 0 deletions src/specify_cli/command_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
)
from ._console import StepTracker, console, select_with_arrows, show_banner
from ._utils import check_tool
from .file_helper import FileHelper


def _stdin_is_interactive() -> bool:
Expand Down Expand Up @@ -790,6 +791,7 @@ def init(
selected_script,
tracker=tracker,
force=force,
files=FileHelper(project_path, allow_symlinks=allow_symlinks),
invoke_separator=resolved_integration.effective_invoke_separator(
integration_parsed_options, project_root=project_path
),
Expand Down
58 changes: 52 additions & 6 deletions src/specify_cli/file_helper.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Scoped filesystem operations; see design/file-helper.md for the contract."""

from dataclasses import dataclass, field
from collections.abc import Callable
import codecs
import errno
import os
Expand All @@ -11,6 +12,7 @@


_PathType = TypeVar("_PathType", bound=PurePath)
MutationObserver = Callable[[Path, bool, bool], None]


def _strip_extended_length_prefix(path: _PathType) -> _PathType:
Expand Down Expand Up @@ -153,11 +155,26 @@ def _require_file(path: Path) -> None:
raise UnsupportedPathError(f"Expected a regular file: {path}")

def mkdir(
self, path: Path | str, *, parents: bool = False, exist_ok: bool = False
self, path: Path | str, *, parents: bool = False, exist_ok: bool = False,
observer: MutationObserver | None = None,
) -> None:
"""Create a directory, preflighting the entire requested hierarchy."""
destination = self._path(path, allow_missing=True)
destination.mkdir(parents=parents, exist_ok=exist_ok)
if observer is None or destination.exists() or (not parents and not destination.parent.exists()):
destination.mkdir(parents=parents, exist_ok=exist_ok)
return
missing = [destination]
if parents:
current = destination.parent
while not current.exists():
missing.append(current)
current = current.parent
for directory in reversed(missing):
if self._path(path, allow_missing=True) != destination:
raise FileHelperError(f"Destination changed during mkdir: {path}")
observer(directory, True, False)
directory.mkdir(exist_ok=exist_ok if directory == destination else True)
Comment thread
Copilot marked this conversation as resolved.
observer(directory, False, False)

def read_bytes(self, path: Path | str) -> bytes:
destination = self._path(path)
Expand All @@ -167,6 +184,20 @@ def read_bytes(self, path: Path | str) -> bytes:
def read_text(self, path: Path | str, *, encoding: str = "utf-8") -> str:
return self.read_bytes(path).decode(encoding)

def stat(
self, path: Path | str, *, follow_symlinks: bool = True, missing_ok: bool = False
) -> os.stat_result | None:
"""Inspect an entry under policy; missing link targets always fail."""
destination = self._path(path, allow_missing=True, follow_leaf=follow_symlinks)
try:
entry = destination.stat(follow_symlinks=follow_symlinks)
except FileNotFoundError:
if missing_ok:
return None
raise
self._entry_mode(destination)
return entry

def create_bytes(self, path: Path | str, content: bytes, *, mode: int = 0o644) -> None:
"""Exclusively create a file; never overwrite an existing entry."""
destination = self._path(path, allow_missing=True, follow_leaf=False)
Expand All @@ -187,11 +218,16 @@ def create_text(
) -> None:
self.create_bytes(path, content.encode(encoding), mode=mode)

def write_bytes(self, path: Path | str, content: bytes, *, mode: int = 0o644) -> None:
def write_bytes(
self, path: Path | str, content: bytes, *, mode: int = 0o644,
observer: MutationObserver | None = None,
) -> None:
"""Atomically create/replace a file, preserving any allowed leaf link."""
destination = self._path(path, allow_missing=True)
if destination.exists():
self._require_file(destination)
if observer is not None:
observer(destination, True, False)
fd, temporary = tempfile.mkstemp(prefix=f".{destination.name}.", dir=destination.parent)
temporary_path = Path(temporary)
try:
Expand All @@ -201,13 +237,16 @@ def write_bytes(self, path: Path | str, content: bytes, *, mode: int = 0o644) ->
if self._path(path, allow_missing=True) != destination:
raise FileHelperError(f"Destination changed during write: {path}")
os.replace(temporary_path, destination)
if observer is not None:
observer(destination, False, False)
finally:
temporary_path.unlink(missing_ok=True)

def write_text(
self, path: Path | str, content: str, *, encoding: str = "utf-8", mode: int = 0o644
self, path: Path | str, content: str, *, encoding: str = "utf-8", mode: int = 0o644,
observer: MutationObserver | None = None,
) -> None:
self.write_bytes(path, content.encode(encoding), mode=mode)
self.write_bytes(path, content.encode(encoding), mode=mode, observer=observer)

def overwrite_text(
self, path: Path | str, content: str, *, encoding: str = "utf-8"
Expand Down Expand Up @@ -257,7 +296,10 @@ def _deletion_plan(self, path: Path, *, recursive: bool) -> list[tuple[Path, boo
plan.append((path, True))
return plan

def delete(self, path: Path | str, *, recursive: bool = False) -> None:
def delete(
self, path: Path | str, *, recursive: bool = False,
observer: MutationObserver | None = None,
) -> None:
"""Delete an entry; recursive deletion never follows descendant links."""
destination = self._path(path, follow_leaf=False)
if destination == self._canonical_root:
Expand All @@ -268,7 +310,11 @@ def delete(self, path: Path | str, *, recursive: bool = False) -> None:
checked = self._path(entry, follow_leaf=False)
if checked != entry:
raise FileHelperError(f"Destination changed during deletion: {entry}")
if observer is not None:
observer(entry, True, True)
if directory:
entry.rmdir()
else:
entry.unlink()
if observer is not None:
observer(entry, False, False)
16 changes: 16 additions & 0 deletions src/specify_cli/integrations/_file_changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
file_change_observer: ContextVar[Callable[[Path, bool, bool], None] | None] = ContextVar(
"integration_file_change_observer", default=None
)
directory_change_observer: ContextVar[Callable[[Path, bool, bool], None] | None] = ContextVar(
"integration_directory_change_observer", default=None
)


def before_file_change(path: Path, *, removal: bool = False) -> None:
Expand All @@ -23,6 +26,19 @@ def after_file_change(path: Path) -> None:
if observer is not None:
observer(path, False, False)

def observe_file_change(path: Path, before: bool, removal: bool = False) -> None:
"""Forward a scoped helper's actual mutation entry to the lifecycle journal."""
if before:
before_file_change(path, removal=removal)
else:
after_file_change(path)

def observe_directory_change(path: Path, before: bool, removal: bool = False) -> None:
"""Observe newly created directories without claiming their contents."""
observer = directory_change_observer.get()
if observer is not None:
observer(path, before, removal)


@contextmanager
def changing_file(path: Path, *, removal: bool = False):
Expand Down
3 changes: 3 additions & 0 deletions src/specify_cli/integrations/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,10 +329,13 @@ def _set_default_integration(
)

if refresh_templates:
from .._file_policy import project_files
files = project_files(project_root)
try:
_install_shared_infra(
project_root,
resolved_script,
files=files,
invoke_separator=_invoke_separator_for_integration(
integration, {"integration_settings": settings}, key, parsed_options,
project_root=project_root,
Expand Down
Loading
Loading