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
127 changes: 127 additions & 0 deletions docs/npm-persistent-path-overlap-followup-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# npm persistent-path overlap follow-up audit — 2026-08

## Finding

The mutation-scope hardening merged in #197 remains valid: before invoking npm's
recursive cache operations, DevClean re-confirms the reviewed cache and refuses
execution when the npm global prefix, user config, diagnostic logs, or default
`_logs` area falls inside the mutation range.

While preparing a later generic-scan performance audit, a broader review of npm's
current configuration schema found additional long-lived path-valued inputs that
must receive the same protection. They do not change npm cache ownership, but
placing one of them inside `_cacache` or an exact npx entry would make the vendor
recursive operation broader than the authority DevClean presents.

The audited current npm source is npm 12.0.2 at commit
`dc43591e6e08e9857c787116b1ed12f074e68c3c`.

Relevant current source semantics include:

- `globalconfig` is a path to the global npm configuration file;
- `global-ignore-file` is a path to user-owned global pack/publish ignore rules;
- `cafile` is a path to a CA trust file;
- `init-module` and its historical `init.module` alias are paths to the user's
npm-init template module;
- `node-gyp` is a path to the node-gyp executable npm may invoke;
- `prefix`, `userconfig`, and `logs-dir` retain the protection added in #197.

npm's config loader computes the normal `globalconfig` default from the current
prefix. npm 12 likewise computes the normal `global-ignore-file` default from the
current prefix, so these are real persistent paths rather than names inferred by
DevClean.

## Compatibility audit

The follow-up must not make an older, otherwise supported npm fail merely because
a newer npm introduced a configuration key.

npm 11.6.2 source was therefore checked separately. The stable protected inputs
used here (`globalconfig`, `cafile`, `init-module`/`init.module`, `node-gyp`, plus
the #197 keys) are present there. `global-ignore-file` is not part of that audited
v11 schema but is present in npm 12.

DevClean now asks the already-bound npm executable for its major version before
the boundary query:

- npm major 11 and earlier use only the stable audited boundary-key set;
- npm major 12 and later additionally query `global-ignore-file`.

If the version cannot be proven, or the requested boundary keys are not returned,
DevClean fails closed rather than guessing.

## Correction

The existing `_require_no_protected_overlap()` execution guard is extended; no
new mutation path or cleanup rule is added.

For every guarded npm vendor mutation, DevClean uses the same reviewed npm CLI and
the same pinned cache environment to:

1. obtain and parse the npm major version;
2. query the version-appropriate boundary keys;
3. require the reviewed base cache to be confirmed again;
4. add the following source-backed persistent/security paths to the overlap
protection set when configured:
- global prefix;
- user config;
- global config;
- CA file;
- npm-init module and its historical alias;
- node-gyp executable;
- configured logs-dir;
- normal `<cache>/_logs` diagnostics;
- npm 12+ global ignore file;
5. refuse the vendor operation if any protected path is equal to or inside the
exact mutation root.

The existing process rechecks, CLI/cache identity binding, exact npx dry-run path
proof, fixed vendor commands, postconditions, and no-filesystem-fallback behavior
are unchanged.

## Scope boundary

This follow-up intentionally protects the audited globally effective top-level
persistent/security path inputs above. It does **not** claim to enumerate every
possible path string npm can encounter in every project, command, package spec,
or registry-scoped configuration key.

Examples such as one-off project-local command inputs, package file specs, or
registry-scoped certificate/key settings have different scopes and cannot be
safely turned into global cleanup ownership merely by discovering a path value.
This PR neither grants nor removes authority for them.

## What this PR does not change

This follow-up does not:

- change `_cacache`, `_npx`, `_tuf`, or `_logs` rule ownership;
- create a raw filesystem TOOL root;
- change USER/KEEP/TOOL/AI lanes;
- add any whole-tree generic deletion authority;
- change the npm maintenance UI or create a second product workflow;
- add generic-scan pruning;
- treat provider-root occupancy as exact reclaim for partial vendor GC.

The npm generic-scan performance audit remains a separate follow-up so traversal
optimization cannot obscure mutation-safety review.

## Regression coverage

Focused Windows tests require:

- npm 11 to query the stable boundary set without requesting the npm-12-only
`global-ignore-file` key;
- npm 12 to query and protect `global-ignore-file`;
- each stable persistent/security path to block a mutation when redirected inside
`_cacache`;
- the same protection to apply to an exact npx entry range;
- nullable optional paths to remain valid when npm reports them as null;
- incomplete boundary output or an unprovable npm major to fail closed;
- cache retargeting immediately before mutation to remain rejected.

## Merge gate

Merge only from the exact final PR head after the normal DevClean gate is green:
lock/dependency checks, Ruff, strict mypy, full pytest/current workflow, Windows
EXE build/upload, and CodeQL.
68 changes: 59 additions & 9 deletions src/devclean/core/npm_maintenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@
from devclean.platform.windows.filesystem import read_file_metadata
from devclean.platform.windows.volumes import is_local_fixed_path

_NPM_STABLE_BOUNDARY_CONFIG_KEYS = (
"cache",
"prefix",
"userconfig",
"globalconfig",
"cafile",
"init-module",
"init.module",
"node-gyp",
"logs-dir",
)
_NPM_V12_BOUNDARY_CONFIG_KEYS = ("global-ignore-file",)
_NPM_BOUNDARY_CONFIG_KEYS = frozenset(
(*_NPM_STABLE_BOUNDARY_CONFIG_KEYS, *_NPM_V12_BOUNDARY_CONFIG_KEYS)
)


@dataclass(frozen=True, slots=True)
class NpmPathIdentity:
Expand Down Expand Up @@ -367,6 +383,21 @@ def _discover_cache_root(tool: NpmPathIdentity, environment: Mapping[str, str])
return Path(str(candidate))


def _npm_major_version(
tool: NpmPathIdentity,
environment: Mapping[str, str],
) -> int:
result = _run_npm(tool, ("--version",), environment, timeout=30)
_require_success(result, "npm --version")
lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
if len(lines) != 1:
raise RuntimeError("npm --version 未返回唯一版本; 已安全停止")
major_text = lines[0].split(".", 1)[0]
if not major_text.isdigit() or int(major_text) < 1:
raise RuntimeError(f"无法解析 npm major 版本: {lines[0]}")
return int(major_text)


def _require_no_protected_overlap(
inventory: NpmStorageInventory,
mutation_root: Path,
Expand All @@ -375,17 +406,22 @@ def _require_no_protected_overlap(
"""Fail closed if npm redirects persistent/user data into this mutation root."""

pinned_env = _npm_environment(inventory.cache_root, environment)
major = _npm_major_version(inventory.npm_tool, pinned_env)
config_keys = list(_NPM_STABLE_BOUNDARY_CONFIG_KEYS)
if major >= 12:
config_keys.extend(_NPM_V12_BOUNDARY_CONFIG_KEYS)
result = _run_npm(
inventory.npm_tool,
("config", "get", "cache", "prefix", "userconfig", "logs-dir"),
("config", "get", *config_keys),
pinned_env,
timeout=30,
)
_require_success(result, "npm config get mutation boundaries")
values = _parse_config_values(result.stdout)
required = {"cache", "prefix", "userconfig", "logs-dir"}
required = set(config_keys)
if set(values) != required:
raise RuntimeError("npm 未完整返回 cache/prefix/userconfig/logs-dir; 已安全停止")
missing = ", ".join(sorted(required - set(values))) or "unknown"
raise RuntimeError(f"npm 未完整返回 mutation boundary 配置 ({missing}); 已安全停止")

confirmed_cache = _config_path(values["cache"], "npm cache", allow_null=False)
if confirmed_cache is None or _normalize(confirmed_cache) != _normalize(
Expand All @@ -396,15 +432,30 @@ def _require_no_protected_overlap(
protected: list[tuple[str, Path]] = [
("default logs", inventory.cache_root / "_logs"),
]
for key, label in (("prefix", "global prefix"), ("userconfig", "user config")):
required_paths = (
("prefix", "global prefix"),
("userconfig", "user config"),
("globalconfig", "global config"),
)
for key, label in required_paths:
path = _config_path(values[key], f"npm {label}", allow_null=False)
if path is None:
raise RuntimeError(f"npm 未返回 {label} 路径; 已安全停止")
protected.append((label, path))

logs_dir = _config_path(values["logs-dir"], "npm logs-dir", allow_null=True)
if logs_dir is not None:
protected.append(("logs-dir", logs_dir))
optional_paths: tuple[tuple[str, str], ...] = (
("cafile", "CA file"),
("init-module", "init module"),
("init.module", "legacy init module"),
("node-gyp", "node-gyp executable"),
("logs-dir", "logs-dir"),
)
if major >= 12:
optional_paths = (*optional_paths, ("global-ignore-file", "global ignore file"))
for key, label in optional_paths:
path = _config_path(values[key], f"npm {label}", allow_null=True)
if path is not None:
protected.append((label, path))

for label, protected_path in protected:
if _path_is_inside(mutation_root, protected_path):
Expand All @@ -415,13 +466,12 @@ def _require_no_protected_overlap(

def _parse_config_values(stdout: str) -> dict[str, str]:
values: dict[str, str] = {}
allowed = {"cache", "prefix", "userconfig", "logs-dir"}
for line in stdout.splitlines():
key, separator, raw = line.partition("=")
if not separator:
continue
normalized = key.strip().casefold()
if normalized not in allowed or normalized in values:
if normalized not in _NPM_BOUNDARY_CONFIG_KEYS or normalized in values:
continue
values[normalized] = raw.strip()
return values
Expand Down
Loading
Loading