Skip to content
Merged
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
116 changes: 100 additions & 16 deletions design/file-helper.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,92 @@
# Scoped FileHelper foundation
# Scoped FileHelper and project policy

`specify_cli.file_helper.FileHelper` owns filesystem operations under one
explicitly established root. This is the first migration stage: existing CLI
commands retain their current behavior and do not yet use this helper. There is
no global CLI flag or process-global policy.

The user-directed persistence requirement belongs to the dependent
project-policy wiring stage: save the setting in `.specify/init-options.json`,
then load and validate it for future invocations and pass the resulting policy
explicitly to helper instances. Only `specify init` exposes and persists the
setting. Every other CLI command reads the current project configuration on
each invocation, so manual edits take effect without a cached policy. There
is no root/global flag and no per-command override flag. The low-level helper
does not read configuration. Strict persisted-policy loading and configuration-
read bootstrap semantics must be coordinated before migration.
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.

Only `specify init --allow-symlinks` exposes the setting; init defaults to deny
and persists the literal JSON boolean `allow_symlinks` in
`.specify/init-options.json`. Re-init without the flag records `false`.
Other project-scoped commands validate the selected project's current
configuration each invocation, including successive invocations in one process.
No global flag, per-command override, mutable global policy, or cache is used.
Projectless help/version/tool operations do not require configuration.
`check` is an optional project consumer: it validates any present policy at its
selected cwd/override boundary before loading installed integrations or checking
tools, but still permits a missing project marker or configuration in cwd.
Existing resolution rules, including `SPECIFY_INIT_DIR`, bundle ancestor search,
direct workflow-file roots and explicit init targets, determine which root is
checked; policy loading does not add ancestor discovery.
Event execution retains its cwd-only selection and does not acquire an
override or project-marker requirement; it preflights any present cwd policy
before invoking handlers. Bundle ancestor search continues only when a marker
is genuinely absent. A nearer forbidden/present invalid `.specify` entry fails
at that candidate rather than selecting an ancestor or initializing elsewhere.

Missing configuration or a missing key means deny. Only literal JSON
`true`/`false` values are valid. Invalid JSON, non-object documents, invalid
encoding, unreadable files and invalid values fail explicitly before mutations
at the project boundary. Dangling configuration links are present forbidden
entries, not missing defaults.

Configuration bootstrap reads **and writes** always use a deny-mode helper,
even when stored operational policy or the init flag allows symlinks.
`.specify` and `init-options.json` below the trusted project root must not be
links or unsupported reparse points. Root/OS aliases retain the foundation's
trusted-boundary treatment. For a new init root, `prepare_init_root` creates its
missing suffix using a deny-mode helper rooted at the nearest existing parent;
an existing explicitly supplied root alias is trusted. `preflight_init_root`
validates the original requested spelling without creating a root, including
missing-parent traversal and dangling-parent evidence before canonicalization.
Before presence probes, every prefix preceding `..` must be an existing
directory: Windows can otherwise fold `missing/../existing` into an apparently
existing target. Valid existing-directory/root-alias traversal remains trusted;
prefix permission and other I/O failures remain explicit errors.
Init's external-adapter lifecycle validates existing target policy before
reading integration records, loading adapters or preparing catalog packages.
When that lifecycle requires a
transaction, it creates the target under the existing lock using the original
requested spelling and `prepare_init_root`, before invoking the inner handler.
The recorded pre-transaction directory state retains that spelling, so a newly
created root does not become a spurious existing-directory conflict. Lock,
journal and handler operations retain their canonical project identity after
the original spelling has been validated.

## Policy handoff for dependent migrations

`specify_cli._file_policy.read_project_options(root)` is the strict bootstrap
reader; it is separate from the legacy tolerant `load_init_options` library
API. `project_files(root)` reads policy afresh and returns an immutable
`FileHelper(root, allow_symlinks=...)`. Pass that returned operation owner
explicitly to domain consumers; do not reconstruct a default helper and lose
the policy. `specify_cli._project.resolve_specify_project_files()` performs the
normal existing-project resolution and returns the same handoff for new CLI
adapters. Commands using another established root call `project_files` on
their actually selected root. Retain a helper only for the current operation;
a later invocation resolves a new helper, so manual edits take effect.

Existing Path-returning resolvers preflight policy without changing their return
type or domain constructors. They do not implicitly equip unmigrated domain
consumers with an operation helper. Failures are translated into existing
command diagnostics, with a single `{"error": "..."}` on stderr and empty
stdout for JSON policy failures; artifact/list surfaces preserve their error
envelope contracts. Artifact list/info validate their explicit project roots in
the shared application operations, so CLI and MCP peer adapters share the same
policy semantics while retaining their own error envelopes.

`save_project_options` and the compatibility `save_init_options` writer use
deny-mode bootstrap I/O. Init merges its owned settings into existing valid
options, preserving unrelated fields. Integration option update/clear/version
paths use the strict reader and retain `allow_symlinks`. Unmigrated tolerant
library readers keep their existing missing/malformed fallback contracts.
Configuration writers validate their deny-mode destination before the existing
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.

```python
from specify_cli.file_helper import FileHelper
Expand Down Expand Up @@ -87,6 +160,15 @@ helpers for independent project/source/managed boundaries.
`encoding="utf-8"`. Replacement files use the supplied mode; metadata and
hard-link identity are not preserved. Atomic means same-directory
`os.replace`, not crash durability.
- `overwrite_text(path, content, encoding="utf-8")` is a **non-atomic** text
upsert with existing parents, used for configuration compatibility. It uses
the same accessed-path/link/containment checks as other operations, follows
only allowed existing contained file targets, and preserves link chains and
the target inode (including hard-link identity and permissions). New files
use `0o666` filtered by the process umask; text encoding and native newline
translation match the previous configuration writer. An encoding or I/O
failure propagates, and a write failure can leave truncated/partial content.
This is not hard-link confinement: a hard-linked alias can observe updates.
- `delete(path, recursive=False)` unlinks files/links or removes directories.
Nonrecursive directory deletion requires an empty directory. The established
root cannot be deleted.
Expand Down Expand Up @@ -129,8 +211,10 @@ 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
paths and an explicit root/policy into the helper before any early resolution.
CLI option wiring, linked command directories, and development-mode
registration remain dependent stages.
Linked command directories and development-mode registration remain dependent
stages. Non-atomic configuration writes preserve legacy metadata semantics;
portable preflight does not guarantee race-free protection against concurrent
path replacement.

Windows path classification and directory-mode reparse rejection have portable
regression tests. Native Windows path and junction tests also exist and are
Expand Down
37 changes: 36 additions & 1 deletion docs/reference/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ specify init [<project_name>]
| `--script sh\|ps\|py` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `py` (Python) |
| `--here` | Initialize in the current directory instead of creating a new one |
| `--force` | Force merge/overwrite when initializing in an existing directory |
| `--allow-symlinks` | Persist permission for contained symlinks in migrated FileHelper operations; default is deny |
| `--ignore-agent-tools` | Skip checks for AI coding agent CLI tools |
| `--preset <id>` | Install a preset during initialization |

Expand All @@ -27,6 +28,32 @@ Use `<project_name>` to create a new directory, or `--here` (or `.`) to initiali

When `--integration` is omitted, interactive terminals prompt you to choose an integration. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot; pass `--integration <key>` to choose a different integration explicitly, or set `SPECKIT_INTEGRATION_DEFAULT` to change the fallback (see [Environment Variables](#environment-variables)).

### Persisted filesystem policy

Only `init` accepts `--allow-symlinks`. It records `allow_symlinks: true` in
`.specify/init-options.json`; omitting it records `false`, including on re-init.
Project-scoped CLI commands read the current value on each invocation, so a
manual edit takes effect immediately. A missing file or key means deny; the
value must be a JSON boolean, not a string, number or null. Invalid/unreadable
configuration is an error, not a fallback. Unrelated recorded init options are
preserved.

The configuration bootstrap hierarchy (`.specify` and `init-options.json`)
must always be non-symlinked below the trusted project root, even in allow mode.
Help, version, check and projectless tool/catalog operations do not gain a
project requirement. Init uses its explicit target, not `SPECIFY_INIT_DIR`.
`check` can consume installed project integrations: it validates any present
policy at its selected cwd or `SPECIFY_INIT_DIR` boundary before loading them
or checking tools. It still works in a projectless cwd without `.specify` or
init options. Help and version do not read project policy.

**Migration limit:** policy is enforced by FileHelper operations, currently
including init-options bootstrap I/O. Loading policy does not yet route all
other command filesystem operations through FileHelper. Shared infrastructure,
registration, managed storage, scripts and runtime operations retain their
existing behavior until migrated. See [FileHelper design](../../design/file-helper.md)
for the boundary and handoff contract.

### Examples

```bash
Expand Down Expand Up @@ -60,7 +87,11 @@ specify init my-project --integration copilot --preset compliance
>
> **Version control.** `specify init` scaffolds a managed `.specify/.gitignore` that excludes machine-local state — `feature.json` (the current-feature pointer, rewritten on every feature switch) and per-machine extension `extensions/*/local-config.yml` overrides — while leaving everything else under `.specify/` (constitution, templates, scripts, extension config) shareable so teams stay aligned. Like the rest of `.specify/`'s shared scripts and templates, the file is tracked in the shared-infrastructure manifest: your edits are preserved on re-init and `specify init --here --force` restores the managed content. It is intentionally left in place by `specify integration uninstall`, which only removes the uninstalled agent's own files.
>
> **Symlinked project roots.** `SPECIFY_INIT_DIR` relocates *where* the project is, not *how* a command treats symlinks: each command keeps its existing cwd-path stance. Commands that traverse and write project files through broad input paths (`bundle`, `workflow run <file>`) refuse a symlinked `.specify/` to preserve write confinement. Other project-scoped commands keep their existing behavior when `SPECIFY_INIT_DIR` points at a project root, which may include following a symlinked `.specify/`.
> **Symlinked project roots.** An explicitly established project-root alias is
> trusted, including OS ancestors above it. The configuration hierarchy below
> that root is always checked in deny mode: `.specify/` and `init-options.json`
> cannot be symlinks. Unmigrated operations elsewhere in the project retain
> their existing symlink behavior.

## Naming Features with the Helper Scripts

Expand Down Expand Up @@ -96,6 +127,10 @@ specify check

Checks that CLI-based AI coding agents are available on your system. IDE-based agents are skipped since they don't require a CLI tool.

Installed project integrations are included when available. Any present
filesystem policy at the selected project boundary must be valid; a projectless
cwd remains supported without configuration.

This command stays offline. If a command behaves like an older Spec Kit version or an expected CLI feature is missing, run `specify self check` to check whether your local CLI is behind the latest release.

## Version Information
Expand Down
6 changes: 6 additions & 0 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,12 @@ def _require_specify_project(*, load_integrations: bool = False) -> Path:
"""
override = _resolve_init_dir_override()
project_root = override if override is not None else Path.cwd()
from ._project import ProjectResolutionError, preflight_project_policy, render_project_error

try:
preflight_project_policy(project_root)
except ProjectResolutionError as exc:
render_project_error(exc)
if (project_root / ".specify").is_dir():
if load_integrations:
from .integrations.installer import IntegrationInstallError, load_installed_integrations
Expand Down
Loading
Loading