From 1160a39db43e790bf5338f93945fb684e53e85e9 Mon Sep 17 00:00:00 2001 From: Heznpc Date: Wed, 19 Aug 2026 19:56:28 +0900 Subject: [PATCH 1/3] Absorb decant's two unique audits, with its fail-open defect inverted decant's cleanup logic already duplicated Modore's storage scan, but two of its judgments had no equivalent here: which Hugging Face models nothing on this machine names, and which registered MCP servers cannot start. Both are ported as stdlib-only scripts on the scree/friction pattern and exposed through the existing read-only MCP surface. The Hugging Face check could not be ported as written. `ContextProbe.swift` returned "unreferenced" whenever its search failed to run -- a search root that did not exist, a failed grep, a mistyped `--projects` argument all produced the same answer as an exhaustive search that found nothing. One typo could therefore mark an entire hub cache safe to delete, and Modore saying "safe to delete" is the one sentence that has to be earned. So the rule is inverted at the point where it is decided: absence of evidence counts only when the search actually happened. A missing root, a file cap reached, or an unreadable subtree now yields `unknown` for every model with the reason named, and `--allow-missing-roots` / `--ignore-unreadable` widen the verdict only when the operator asks for it. `search_is_complete` is one function so the whole rule can be read at once, and every failure path is pinned by a test. Matching is case-insensitive and substring-based for the same reason: over-catching keeps a model, under-catching loses one. mcpaudit carries the same rule where it applies -- a verdict that depends on PATH is withheld when PATH is unusable rather than reported as `dead` -- and two deliberate deviations from decant. Servers are named rather than anonymised to `server#N`, because a hygiene report the operator cannot act on is not a hygiene report, and a server name is configuration metadata of the kind scree already emits. What stays hidden is what was sensitive: `env` is a key count, never keys or values. Neither script ships inside the signed bundle -- they have no Swift caller, so the runtime-completeness test lists them beside friction.py, and giving either one a view means moving it into RUNTIME_FILES. Verified: 121 tests across the four affected suites, full suite 481 passed, release_smoke clean. Live run on this machine -- 5 MCP servers, all healthy; the mistyped-root case returns `verdicts_withheld: true` through the MCP surface rather than a list of false orphans. --- CHANGELOG.md | 3 + README.md | 12 +- docs/ARCHITECTURE.md | 17 +- scripts/hfscan.py | 443 ++++++++++++++++++++++++++++++++ scripts/mcp_server.py | 157 ++++++++++- scripts/mcpaudit.py | 349 +++++++++++++++++++++++++ scripts/release_smoke.py | 2 + tests/test_hfscan.py | 230 +++++++++++++++++ tests/test_mcp_server.py | 75 +++++- tests/test_mcpaudit.py | 183 +++++++++++++ tests/test_service_contracts.py | 15 +- 11 files changed, 1470 insertions(+), 16 deletions(-) create mode 100644 scripts/hfscan.py create mode 100644 scripts/mcpaudit.py create mode 100644 tests/test_hfscan.py create mode 100644 tests/test_mcpaudit.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a3bbe1d..7049d7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to this project are documented here. Format loosely follows ## [Unreleased] ### Added +- **Hugging Face cache audit — `scripts/hfscan.py`.** Absorbed from decant's `ContextProbe.swift` (that repository is being retired; this was one of two capabilities Modore did not already have). Derives each cached model's identifier from its hub directory name (`models--Qwen--Qwen2.5-Coder-1.5B-Instruct` → the `Qwen/…` slug and the bare leaf) and searches the given roots for any occurrence, case-insensitively, so that over-catching keeps a model rather than losing one. **The original's central defect is inverted here.** decant returned "unreferenced" whenever the search could not run — a missing search root, a failed grep, a mistyped `--projects` argument all produced the same answer as a genuinely exhaustive search that found nothing, so one typo could mark an entire hub cache safe to delete. Absence of evidence is only evidence of absence when the search actually happened: a root that does not exist, a file cap reached, or a subtree that could not be read now yields `unknown` for every model with the reason stated, and `--allow-missing-roots` / `--ignore-unreadable` widen the verdict only when the operator asks. Metadata-only: file contents are matched in memory and discarded, and the only paths emitted are the files that *do* reference a model, masked through `scree.mask_text`. +- **MCP config hygiene — `scripts/mcpaudit.py`.** Absorbed from decant's `MCPHygiene.swift`. Reads `~/.claude.json` (including servers nested under per-project blocks), both Claude Desktop config locations, and `~/.mcp.json`, and reports entries that cannot start: `dead` (command does not resolve, or a script argument points at a path that is gone), `duplicate` (same command and args as another entry), `manual-review` (an `env` block is present), `unknown` (no command at all). Two deliberate deviations from the original: servers are named rather than anonymised to `server#N`, because a hygiene report the operator cannot act on is not one, and a server name is configuration metadata of the same kind scree already emits — while `env` is reported as a key *count*, never as keys or values. And a verdict that depends on PATH is withheld as `unknown` when PATH is unusable rather than reported as `dead`, the same fail-safe rule hfscan applies to its search. Read-only in the strong sense: the module has no code path that edits a config, disables a server, or starts one, pinned by a test. +- **Two more read-only MCP tools — `hf_orphans`, `mcp_hygiene`.** The audits above, exposed through the existing surface on the same terms as `scree_report` and `friction_scan`: allowlisted by name, annotated read-only and non-destructive, forwarding what the judgment script prints rather than reimplementing it. `hf_orphans` restates `search_complete` as a top-level `verdicts_withheld` flag so an incomplete search cannot be misread as "no orphans found", and its `roots` argument — the one place a caller supplies a path — is bounded to 8 entries and screened so a value can never be read as an option by the script it is passed to. - **Operator-friction scan — `scripts/friction.py`.** Deterministic port of canary's `lib/sessions/friction.ts` (that repository is now a frozen paper archive; this was its one capability Modore did not already have). Flags the user turns where the operator pushed back on agent behaviour, across nine categories — wrong-action, no-research-assertion, stalling-approval, rule-contamination, over-orchestration-token, stale-repetition, verbosity, tone-attitude, other-ai-friction — at severity 1-3. Keyword and tone matching only, no model in the judgment path. Claude Code and Codex sessions are discovered through scree's own collectors rather than a second traversal; Gemini CLI chats (`~/.gemini/tmp/*/chats`, joined to a workspace through the project registry) and Claude Desktop local-agent sessions are added because scree does not traverse either — its Gemini collector reads only the project registry, and it has no Claude Desktop collector. Content contract: user-authored turns only, quotes capped at 200 characters and masked by default (`--raw-quotes` opts out), nothing written. `--json` serves the structured report. First live run over a 30-day window: 200 sessions, 3,563 user turns, 430 findings (rage 247 / irritation 60 / correction 123). - **Read-only MCP surface — `scripts/mcp_server.py`.** A zero-dependency stdio JSON-RPC server exposing three tools: `scree_report` (join, retention forecast, sole-copy and orphan verdicts, by section), `friction_scan` (the taxonomy above, filterable by store/category/minimum severity), and `system_scan_summary` (the storage and security scan result already on disk, with its age). Each tool runs `scree.py --json` or `friction.py --json` and forwards the output, so no judgment is reimplemented and the CLI, the Mac app, and the MCP surface cannot disagree. Cleanup, deletion, and scan execution are deliberately not exposed — Modore's destructive path is gated on an on-screen human approval, and an agent-reachable bypass would void it; pinned by a test that exercises every tool and asserts the only processes ever spawned are the two judgment scripts. The read-only contract is enforced where tools are registered, ported from AirMCP's own Swift MCP server (`IOSPreviewContract`): a tool is reachable only if it is on an explicit name allowlist and annotated read-only and non-destructive, so one added without a deliberate edit fails closed rather than being merely unlisted. All results are fenced as untrusted machine-derived data. - **scree lineage: what the sessions remember versus what the disk still holds.** Every work path appearing in session records is classified alive+git / alive+plain / vanished, using existence and a `.git` presence check only; macOS case-variant ghosts (the same path recorded under different casings) are merged by casefold and reported once with their spellings. First live run: 422 paths — 118 alive git repos, 189 alive plain directories, 115 vanished (session records their only trace), 4 case ghosts. Consumers such as balance's lineup-gap diff read `--json .lineage`; scree itself reports universal facts only. diff --git a/README.md b/README.md index 959a587..03b8272 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,10 @@ python3 scripts/mcp_server.py --tools # inspect the surface without speaking - `scree_report` — the join, retention forecast, orphan/sole-copy/lineage judgment, by section, with every truncation reported. - `friction_scan` — the pushback taxonomy, filterable by store, category, and minimum severity. +- `hf_orphans` — which models in the Hugging Face hub cache no project file names, and how many gigabytes those account for. Reports `search_complete`; when the search could not be exhaustive every model is `unknown`, never `unreferenced`. +- `mcp_hygiene` — registered MCP servers that cannot start: dead command, missing script path, duplicate entry, or an `env` block worth a human look (reported as a key count, never as keys or values). - `system_scan_summary` — the storage and security scan result *already on disk*, with its age, because a stale result read as current is the failure mode here. -- **A thin layer, not a second implementation** — each tool runs `scree.py --json` or `friction.py --json` and forwards what it prints, so the CLI, the Mac app, and the MCP surface cannot disagree about what is true. +- **A thin layer, not a second implementation** — each tool runs one judgment script with `--json` and forwards what it prints, so the CLI, the Mac app, and the MCP surface cannot disagree about what is true. - **Read-only by contract, enforced at registration** — a tool is reachable only if it is on an explicit allowlist and annotated read-only and non-destructive; one added without a deliberate edit fails closed. Cleanup, deletion, and scan execution are deliberately absent. Modore gates destruction on an approval a human grants on screen; an agent-reachable bypass would not be a feature, it would be the end of that guarantee. Every result is fenced as untrusted data. ### 2. Why is my PC this busy? @@ -69,7 +71,9 @@ A fan that will not stop, CPU/GPU load while idle, an unknown process, a strange - **Two OS editions under one brand**: Modore for Windows and Modore for Mac share the same promise — explain local machine state in plain language without deleting anything automatically. - **Mac Edition — AI-agent session audit**: `scree` (above) is the flagship Mac capability — cross-tool join, retention forecast, orphan/sole-copy/lineage judgment, metadata-only. - **Mac Edition — operator-friction scan**: `friction` classifies the turns where the operator pushed back on agent behaviour across Claude Code, Codex, Gemini CLI, and Claude Desktop transcripts — nine categories, severity 1-3, deterministic keyword/tone matching, user-authored turns only, quotes masked by default. -- **Read-only MCP surface**: a zero-dependency stdio MCP server exposing scree, friction, and the existing storage/security scan summary to an agent mid-session. Judgment only — no cleanup, no deletion, no scan execution. +- **Mac Edition — Hugging Face cache audit**: `hfscan` cross-references every cached model against the code on this machine and reports which ones nothing names. An incomplete search withholds the verdict instead of guessing. +- **Mac Edition — MCP config hygiene**: `mcpaudit` reads the registered MCP servers and reports the entries that cannot start. It never edits a config, disables a server, or starts one. +- **Read-only MCP surface**: a zero-dependency stdio MCP server exposing scree, friction, the two audits above, and the existing storage/security scan summary to an agent mid-session. Judgment only — no cleanup, no deletion, no scan execution. - **Mac Edition scanner**: Bash + JXA collectors for macOS security context, launchd/login items, Gatekeeper/SIP/XProtect, network/listening ports, installed-app size, and developer-runtime incidents. Every collector reports `ok`, `permission_denied`, `unavailable`, `timed_out`, or `failed`; a missing required collector can never become a safe verdict. - **Mac Edition app**: the native SwiftUI app presents one incident judgment followed by evidence, likely impact, and approval-gated recovery; bounded local history keeps the judgment without storing raw commands or URLs. Browser automation is grouped into roots with PID, parent, elapsed time, channel, profile type, and a privacy-preserving controller label. - **Windows Edition**: PowerShell 5.1+ scanner focused on Korean banking/government plugin context, Windows Defender, Sysinternals-backed signature/autoruns coverage, networking, startup entries, scheduled tasks, recent installs, and the 5-minute idle CPU monitor. @@ -238,7 +242,9 @@ modore/ ├── scripts/ │ ├── scree.py AI-agent session & residue audit (metadata-only) │ ├── friction.py operator-pushback scan over the same session stores -│ ├── mcp_server.py read-only MCP surface (scree · friction · scan summary) +│ ├── hfscan.py Hugging Face hub cache: which models nothing here names +│ ├── mcpaudit.py MCP config hygiene: registered servers that cannot start +│ ├── mcp_server.py read-only MCP surface (scree · friction · hfscan · mcpaudit · scan summary) │ ├── menu.ps1 Windows interactive menu │ ├── scanner.ps1 Windows scanner │ ├── monitor.ps1 Windows 5-min idle monitor diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 68310ac..53befe2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -50,6 +50,21 @@ Every app build embeds an explicit runtime allowlist under `Contents/Resources/r mask-by-default Markdown export for records about to expire. There is no bulk-export path, and no cleanup recipe consumes scree output. +## Absorbed audits (hfscan, mcpaudit) + +- `scripts/hfscan.py` judges the Hugging Face hub cache: it derives each cached model's identifier + from its hub directory name and searches the given roots for any occurrence. Absorbed from + decant's `ContextProbe.swift` with that module's central defect inverted — an incomplete search + (missing root, file cap reached, unreadable subtree) reports every model `unknown`, never + `unreferenced`. Absence of evidence counts only when the search actually ran; the escape hatches + are explicit flags, never a side effect of a mistyped argument. +- `scripts/mcpaudit.py` judges MCP configuration: which registered servers cannot start. Absorbed + from decant's `MCPHygiene.swift`. `env` is reported as a key count only, and any verdict that + depends on PATH is withheld when PATH is unusable — the same fail-safe rule as above. +- Both are metadata-only, write nothing, and start nothing. Neither ships inside the signed app + bundle: they have no Swift caller, so they stay CLI-and-MCP surfaces, and a test pins that + giving either one a view means moving it into `RUNTIME_FILES`. + ## Cleanup invariants - No caller-supplied deletion path. @@ -74,7 +89,7 @@ Changes to outbound networking, signature verification, cleanup targets, standal ## Verification map -- `python3 -I -B -m pytest tests/ -q`: rule/report/runtime contracts and destructive-boundary tests in isolated fixtures — including `tests/test_scree.py`, which pins scree's no-content-leak, masking, and single-session-export contracts. +- `python3 -I -B -m pytest tests/ -q`: rule/report/runtime contracts and destructive-boundary tests in isolated fixtures — including `tests/test_scree.py`, which pins scree's no-content-leak, masking, and single-session-export contracts, and `tests/test_hfscan.py`, which pins that every way a reference search can fail yields a withheld verdict rather than a false orphan. - `swift test --package-path macos/Modore -Xswiftc -warnings-as-errors -Xswiftc -strict-concurrency=complete`: native model, selection, history, presentation, and runtime-staging tests under the CI compiler policy. - `python3 -I -B scripts/release_smoke.py`: OS-specific source allowlists plus secret/PII/archive-structure audit. - `scripts/package_macos_release.sh --local`: strict Universal 2 standalone app/DMG build under `dist/local/`, clearly unsigned for distribution and never overwriting a release artifact. Git, Swift/Xcode, Python audit, signing, and disk-image tools run from a minimal environment; metadata records the selected developer directory and Swift version. diff --git a/scripts/hfscan.py b/scripts/hfscan.py new file mode 100644 index 0000000..8c10f10 --- /dev/null +++ b/scripts/hfscan.py @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Hfscan, Modore's Hugging Face cache audit: which downloaded models nothing here loads. + +A hub cache is the one big directory a treemap cannot judge. Every entry looks +identical from the outside -- a few gigabytes of weights with an opaque name -- +and the only thing that separates "still in use" from "downloaded once for an +experiment two quarters ago" is whether any code on this machine still names it. +Scree answers that question for session stores; this module answers it for +`~/.cache/huggingface/hub`, by the same rule: cross-reference, then report +evidence, never act. + +Ported from decant's `ContextProbe.swift` (absorbed 2026-08), with its central +defect inverted. The original returned "unreferenced" whenever the search could +not run -- a missing search root, a failed grep, a bad `--projects` argument all +produced the same answer as a genuinely exhaustive search that found nothing. +One typo could therefore mark an entire hub cache as safe to delete. Absence of +evidence is only evidence of absence when the search actually happened, so here +an incomplete search yields `unknown` for every model and says why. The escape +hatches (`--allow-missing-roots`, `--ignore-unreadable`) exist so the operator +can widen the verdict deliberately, never by accident. + +Privacy contract (metadata-only output): +- reads project files only to test whether a model identifier occurs in them; + file contents are matched in memory and discarded, never retained or emitted; +- the only paths emitted are the files that DO reference a model, masked + through `scree.mask_text`; +- writes nothing; all output goes to stdout. + +Judgment limits (preview-grade evidence, not deletion authorization): +- a model can be referenced by something this search does not read -- a + notebook output, a compiled binary, a remote config, a shell history, a + container image, or simply a repository outside the given roots; +- matching is substring-based and case-insensitive, which over-catches (a + model name appearing in prose counts as a reference). That bias is + deliberate: over-catching keeps a model, under-catching loses one; +- every verdict carries `evidence: preview` and `requires_revalidation: true`. + Recovery for a wrong call is a re-download, which costs bandwidth, not work -- + but that is a reason to be calm about mistakes, not to skip revalidation. +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from pathlib import Path +from typing import Iterable, Optional + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + # Python isolated mode (-I, which CI and the app's runner both use) + # intentionally omits the script directory. Import only the sibling + # helper from this resolved, repository-controlled directory. + sys.path.insert(0, str(SCRIPT_DIR)) + +from scree import mask_text # noqa: E402 + +HUB_DIR_PREFIX = "models--" +DEFAULT_ROOTS = ("~/IdeaProjects",) + +# Text-ish files a model identifier could plausibly be named in. Extending this +# set widens the search and can only move verdicts toward `referenced`, which is +# the safe direction; narrowing it is what needs justification. +SEARCHABLE_SUFFIXES = frozenset({ + ".py", ".ipynb", ".pyi", ".cfg", ".ini", ".toml", ".yaml", ".yml", ".json", + ".jsonl", ".md", ".mdx", ".txt", ".rst", ".env", ".sh", ".bash", ".zsh", + ".fish", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".svelte", ".vue", + ".swift", ".rs", ".go", ".java", ".kt", ".kts", ".scala", ".rb", ".lua", + ".r", ".jl", ".c", ".h", ".cc", ".cpp", ".hpp", ".m", ".mm", ".cs", ".php", + ".pl", ".sql", ".tf", ".tfvars", ".gradle", ".properties", ".plist", + ".xml", ".html", ".css", ".scss", ".lock", ".conf", ".service", ".tpl", +}) +SEARCHABLE_NAMES = frozenset({ + "Dockerfile", "Containerfile", "Makefile", "Justfile", "Procfile", + "requirements.txt", "Pipfile", "environment.yml", ".env", +}) + +# Build output and dependency trees. A model named only inside one of these is +# named in a file that was generated from a source file the search does read. +SKIP_DIRS = frozenset({ + ".git", ".hg", ".svn", "node_modules", "__pycache__", ".mypy_cache", + ".pytest_cache", ".ruff_cache", ".tox", ".venv", "venv", "env", + ".build", "build", "dist", "target", "out", ".next", ".nuxt", ".svelte-kit", + ".gradle", ".terraform", ".idea", ".vscode-test", "DerivedData", "Pods", + ".cache", ".turbo", ".parcel-cache", "coverage", ".DS_Store", +}) + +MAX_FILE_BYTES = 2 * 1024 * 1024 +DEFAULT_MAX_FILES = 200_000 + + +# --------------------------------------------------------------------------- +# Hub discovery +# --------------------------------------------------------------------------- + +def hub_path(home: Path, env: Optional[dict] = None) -> Path: + """The hub cache this machine actually uses. + + `huggingface_hub` resolves HF_HUB_CACHE first, then HF_HOME/hub, then the + XDG-ish default. Reading the same variables means an operator who moved the + cache is audited where the cache is, not where it used to be. + """ + env = os.environ if env is None else env + explicit = env.get("HF_HUB_CACHE") or env.get("HUGGINGFACE_HUB_CACHE") + if explicit: + return Path(explicit).expanduser() + hf_home = env.get("HF_HOME") + if hf_home: + return Path(hf_home).expanduser() / "hub" + return home / ".cache" / "huggingface" / "hub" + + +def hub_tokens(dir_name: str) -> list[str]: + """Search tokens for one hub directory name. + + `models--Qwen--Qwen2.5-Coder-1.5B-Instruct` is referenced in code as either + the full `Qwen/Qwen2.5-Coder-1.5B-Instruct` slug or, in loaders that carry + the org separately, the bare leaf. Both are matched; the bare leaf is the + looser of the two and is what keeps a model when only half of it is named. + """ + if not dir_name.startswith(HUB_DIR_PREFIX): + return [dir_name] if dir_name else [] + parts = [p for p in dir_name[len(HUB_DIR_PREFIX):].split("--") if p] + if not parts: + return [dir_name] + slug = "/".join(parts) + tokens = {slug, parts[-1]} + return sorted(t for t in tokens if t) + + +def _tree_size(path: Path) -> tuple[int, int]: + """(bytes, unreadable_dirs) for one model directory. + + Symlinks are counted at link size, not target size: the hub stores each + revision as symlinks into a shared `blobs/` directory, and following them + would bill the same weights once per revision. + """ + total = 0 + unreadable = 0 + for current, _dirnames, filenames in os.walk(path, followlinks=False, + onerror=lambda _e: None): + for name in filenames: + try: + total += os.lstat(os.path.join(current, name)).st_size + except OSError: + unreadable += 1 + return total, unreadable + + +def collect_models(hub: Path) -> list[dict]: + models: list[dict] = [] + try: + entries = sorted(hub.iterdir()) + except OSError: + return models + for entry in entries: + if not entry.name.startswith(HUB_DIR_PREFIX): + continue + if not entry.is_dir() or entry.is_symlink(): + continue + size, unreadable = _tree_size(entry) + try: + mtime = entry.stat().st_mtime + except OSError: + mtime = 0.0 + models.append({ + "name": entry.name, + "tokens": hub_tokens(entry.name), + "size_bytes": size, + "unreadable_entries": unreadable, + "last_modified": _iso(mtime) if mtime else None, + }) + models.sort(key=lambda m: (-m["size_bytes"], m["name"])) + return models + + +# --------------------------------------------------------------------------- +# Reference search +# --------------------------------------------------------------------------- + +def _is_searchable(name: str) -> bool: + if name in SEARCHABLE_NAMES: + return True + return os.path.splitext(name)[1].lower() in SEARCHABLE_SUFFIXES + + +def search_references(roots: list[Path], models: list[dict], *, + max_files: int = DEFAULT_MAX_FILES, + hits_per_model: int = 5) -> tuple[dict[str, list[str]], dict]: + """One walk over the roots, testing every model's tokens against each file. + + Returns (hits by model name, search stats). The stats are what the verdict + rests on: a search that was truncated, that could not read part of the tree, + or that read nothing at all has not established absence, and the caller + turns that into `unknown` rather than `unreferenced`. + """ + needles: list[tuple[str, str]] = [] + for model in models: + for token in model["tokens"]: + if token: + needles.append((model["name"], token.lower())) + + hits: dict[str, list[str]] = {m["name"]: [] for m in models} + stats = { + "files_scanned": 0, + "bytes_scanned": 0, + "files_skipped_large": 0, + "files_unreadable": 0, + "dirs_unreadable": 0, + "truncated": False, + } + if not needles: + return hits, stats + + for root in roots: + for current, dirnames, filenames in os.walk( + root, followlinks=False, + onerror=lambda _e: stats.__setitem__( + "dirs_unreadable", stats["dirs_unreadable"] + 1)): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] + for name in filenames: + if stats["files_scanned"] >= max_files: + stats["truncated"] = True + return hits, stats + if not _is_searchable(name): + continue + full = os.path.join(current, name) + try: + if os.path.getsize(full) > MAX_FILE_BYTES: + stats["files_skipped_large"] += 1 + continue + with open(full, "rb") as handle: + raw = handle.read(MAX_FILE_BYTES) + except OSError: + stats["files_unreadable"] += 1 + continue + stats["files_scanned"] += 1 + stats["bytes_scanned"] += len(raw) + # Case-insensitive substring: over-catching keeps a model, + # under-catching loses one, so the loose test is the safe one. + text = raw.decode("utf-8", errors="ignore").lower() + if not text: + continue + for model_name, token in needles: + if len(hits[model_name]) >= hits_per_model: + continue + if token in text: + if full not in hits[model_name]: + hits[model_name].append(full) + return hits, stats + + +def search_is_complete(stats: dict, missing_roots: list[str], *, + allow_missing_roots: bool, + ignore_unreadable: bool) -> tuple[bool, list[str]]: + """Did the search actually establish that a model is named nowhere? + + This is the inverted decant defect, stated as one function. Every condition + below is a way for the search to have not happened; each one alone is enough + to withhold an `unreferenced` verdict. + """ + reasons: list[str] = [] + if missing_roots and not allow_missing_roots: + reasons.append("search-root-missing") + if stats["truncated"]: + reasons.append("file-cap-reached") + if stats["files_scanned"] == 0: + reasons.append("no-files-read") + if not ignore_unreadable and (stats["dirs_unreadable"] or stats["files_unreadable"]): + reasons.append("tree-partially-unreadable") + return (not reasons), reasons + + +# --------------------------------------------------------------------------- +# Report +# --------------------------------------------------------------------------- + +def _iso(ts: float) -> str: + return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(ts)) + + +def build_report(home: Path, roots: list[Path], *, + max_files: int = DEFAULT_MAX_FILES, + allow_missing_roots: bool = False, + ignore_unreadable: bool = False, + env: Optional[dict] = None) -> dict: + hub = hub_path(home, env) + hub_exists = hub.is_dir() + models = collect_models(hub) if hub_exists else [] + + present_roots = [r for r in roots if r.is_dir()] + missing_roots = [str(r) for r in roots if not r.is_dir()] + + hits, stats = search_references(present_roots, models, max_files=max_files) + complete, incomplete_reasons = search_is_complete( + stats, missing_roots, + allow_missing_roots=allow_missing_roots, + ignore_unreadable=ignore_unreadable) + + entries = [] + for model in models: + referenced_by = hits.get(model["name"], []) + if referenced_by: + verdict, reason = "referenced", "named-in-project-file" + elif complete: + verdict, reason = "unreferenced", "no-occurrence-in-completed-search" + else: + verdict, reason = "unknown", "search-incomplete" + entries.append({ + "name": model["name"], + "tokens": model["tokens"], + "size_bytes": model["size_bytes"], + "last_modified": model["last_modified"], + "verdict": verdict, + "reason": reason, + "referenced_by": [mask_text(p, home) for p in referenced_by], + }) + + unreferenced = [e for e in entries if e["verdict"] == "unreferenced"] + return { + "generated_at": _iso(time.time()), + "hub": { + "path": mask_text(str(hub), home), + "exists": hub_exists, + "model_count": len(entries), + "total_bytes": sum(e["size_bytes"] for e in entries), + }, + "search": { + "roots": [mask_text(str(r), home) for r in present_roots], + "roots_missing": [mask_text(r, home) for r in missing_roots], + "complete": complete, + "incomplete_reasons": incomplete_reasons, + **stats, + }, + "models": entries, + "summary": { + "referenced": sum(1 for e in entries if e["verdict"] == "referenced"), + "unreferenced": len(unreferenced), + "unknown": sum(1 for e in entries if e["verdict"] == "unknown"), + "unreferenced_bytes": sum(e["size_bytes"] for e in unreferenced), + }, + "evidence": "preview", + "requires_revalidation": True, + } + + +def _format_size(size_bytes: int) -> str: + value = float(size_bytes) + for unit in ("B", "KB", "MB", "GB", "TB"): + if value < 1024 or unit == "TB": + return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} B" + value /= 1024 + return f"{value:.1f} TB" + + +def render_report(report: dict, limit: int) -> str: + hub = report["hub"] + search = report["search"] + summary = report["summary"] + lines = ["Hugging Face 캐시 감사 (읽기 전용)", ""] + + if not hub["exists"]: + lines.append(f"허브 캐시가 없습니다: {hub['path']}") + return "\n".join(lines) + + lines.append(f"허브: {hub['path']}") + lines.append(f"모델 {hub['model_count']}개 · {_format_size(hub['total_bytes'])}") + lines.append("") + + if not search["complete"]: + lines.append("⚠ 검색이 완결되지 않아 '미참조' 판정을 내리지 않습니다.") + lines.append(f" 사유: {', '.join(search['incomplete_reasons'])}") + for missing in search["roots_missing"]: + lines.append(f" 없는 검색 루트: {missing}") + lines.append(" 참조가 없다는 결론은 검색이 실제로 끝났을 때만 성립합니다.") + lines.append("") + else: + lines.append(f"검색 완결: 파일 {search['files_scanned']:,}개 " + f"· {_format_size(search['bytes_scanned'])}") + lines.append("") + + lines.append(f"참조됨 {summary['referenced']} · 미참조 {summary['unreferenced']} " + f"· 판정보류 {summary['unknown']}") + if summary["unreferenced"]: + lines.append(f"미참조 모델이 차지한 용량: {_format_size(summary['unreferenced_bytes'])}") + lines.append("") + + shown = report["models"][:limit] + for model in shown: + mark = {"referenced": "유지", "unreferenced": "미참조", "unknown": "판정보류"} + lines.append(f"[{mark[model['verdict']]}] {model['name']} " + f"· {_format_size(model['size_bytes'])}") + if model["referenced_by"]: + for path in model["referenced_by"][:2]: + lines.append(f" 참조: {path}") + else: + lines.append(f" {model['reason']}") + if len(report["models"]) > limit: + lines.append(f"... 그 외 {len(report['models']) - limit}개 생략") + + lines.append("") + lines.append("미참조는 삭제 승인이 아니라 preview 증거입니다. 이 검색이 읽지 않는 곳" + "(노트북 출력, 컨테이너 이미지, 지정 루트 밖 레포)에서 참조될 수 있습니다.") + return "\n".join(lines) + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser( + description="Audit the Hugging Face hub cache for models nothing on this machine names.") + parser.add_argument("--root", action="append", dest="roots", type=Path, default=None, + help=f"search root, repeatable (default: {' '.join(DEFAULT_ROOTS)})") + parser.add_argument("--max-files", type=int, default=DEFAULT_MAX_FILES, + help="stop after reading this many files (a truncated search cannot judge)") + parser.add_argument("--allow-missing-roots", action="store_true", + help="judge even though a named search root does not exist " + "(off by default: a mistyped root is the classic false orphan)") + parser.add_argument("--ignore-unreadable", action="store_true", + help="judge even though part of the tree could not be read") + parser.add_argument("--json", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--limit", type=int, default=20, help=argparse.SUPPRESS) + parser.add_argument("--home", type=Path, default=Path.home(), help=argparse.SUPPRESS) + args = parser.parse_args(argv) + + raw_roots = args.roots if args.roots else [Path(r) for r in DEFAULT_ROOTS] + roots = [Path(os.path.expanduser(str(r))) for r in raw_roots] + + report = build_report( + args.home, roots, + max_files=args.max_files, + allow_missing_roots=args.allow_missing_roots, + ignore_unreadable=args.ignore_unreadable) + + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2)) + else: + print(render_report(report, args.limit)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/mcp_server.py b/scripts/mcp_server.py index bc130bd..196de6a 100644 --- a/scripts/mcp_server.py +++ b/scripts/mcp_server.py @@ -80,9 +80,14 @@ SCREE = SCRIPT_DIR / "scree.py" FRICTION = SCRIPT_DIR / "friction.py" +HFSCAN = SCRIPT_DIR / "hfscan.py" +MCPAUDIT = SCRIPT_DIR / "mcpaudit.py" SCREE_TIMEOUT = 300 FRICTION_TIMEOUT = 300 +# hfscan walks whole project trees; mcpaudit reads four small JSON files. +HFSCAN_TIMEOUT = 600 +MCPAUDIT_TIMEOUT = 60 # scree's full report is large (hundreds of lineage paths on a working machine). # Sections are selectable and lists are truncated, but never silently: every @@ -97,6 +102,14 @@ ) +MCP_HYGIENE_STATUSES = ("dead", "unknown", "duplicate", "manual-review") + +# hfscan's search roots are the one place a caller supplies a path. Bounded so +# an agent cannot turn a read-only report into a whole-disk walk, and screened +# so a value can never be read as an option by the script it is passed to. +MAX_HF_ROOTS = 8 + + class ToolFailure(Exception): """A tool could not answer. Reported to the caller as an MCP tool error.""" @@ -264,6 +277,81 @@ def _locate_scan_result() -> tuple[Optional[Path], list[str]]: return None, checked +def _roots_arg(args: dict) -> list[str]: + """Optional search roots, or [] to let hfscan use its own default. + + A root that does not exist is not rejected here -- hfscan withholds every + verdict when one is missing, which is a more useful answer than an argument + error, and is exactly the failure this port was written to fix. + """ + value = args.get("roots") + if value is None: + return [] + if not isinstance(value, list) or not all(isinstance(v, str) for v in value): + raise ToolFailure("roots must be a list of strings") + if len(value) > MAX_HF_ROOTS: + raise ToolFailure(f"roots accepts at most {MAX_HF_ROOTS} paths") + for root in value: + if not root.strip(): + raise ToolFailure("roots must not contain an empty path") + if root.startswith("-"): + raise ToolFailure("a search root must be a path, not an option") + return value + + +def tool_hf_orphans(args: dict) -> dict: + limit = _int_arg(args, "limit", default=20, minimum=1, maximum=200) + max_files = _int_arg(args, "max_files", default=200_000, minimum=1000, maximum=2_000_000) + + arguments = ["--json", "--max-files", str(max_files)] + for root in _roots_arg(args): + arguments += ["--root", root] + report = _run_json(HFSCAN, arguments, HFSCAN_TIMEOUT) + + search = report.get("search") or {} + models = report.get("models") or [] + # Largest first, and never the whole hub: the answer an agent needs is + # which few models are worth a human look, not an inventory. + ordered = sorted(models, key=lambda m: -(m.get("size_bytes") or 0)) + items, note = _truncate(ordered, limit) + return { + "evidence": report.get("evidence"), + "requires_revalidation": report.get("requires_revalidation"), + "hub": report.get("hub"), + "search": search, + "search_complete": search.get("complete"), + # Stated at the top level because it is the one thing a caller must not + # miss: an incomplete search returns `unknown`, never `unreferenced`. + "verdicts_withheld": not search.get("complete", False), + "summary": report.get("summary"), + "models": items, + **note, + } + + +def tool_mcp_hygiene(args: dict) -> dict: + limit = _int_arg(args, "limit", default=25, minimum=1, maximum=200) + status = _enum_arg(args, "status", MCP_HYGIENE_STATUSES + (None,), None) + report = _run_json(MCPAUDIT, ["--json"], MCPAUDIT_TIMEOUT) + + findings = report.get("findings") or [] + if status: + findings = [f for f in findings if f.get("status") == status] + items, note = _truncate(findings, limit) + return { + "evidence": report.get("evidence"), + "requires_revalidation": report.get("requires_revalidation"), + "configs": report.get("configs"), + "config_errors": report.get("config_errors"), + "server_count": report.get("server_count"), + "path_available": report.get("path_available"), + "summary": report.get("summary"), + "filters": {"status": status}, + "findings": items, + **note, + } + + def tool_system_scan_summary(args: dict) -> dict: limit = _int_arg(args, "limit", default=10, minimum=1, maximum=100) path, checked = _locate_scan_result() @@ -432,6 +520,72 @@ def _enum_arg(args: dict, name: str, allowed: tuple, default): "annotations": {"title": "Friction — where the operator pushed back", **READ_ONLY}, "handler": tool_friction_scan, }, + { + "name": "hf_orphans", + "title": "Hugging Face cache — models nothing here names", + "description": ( + "Which models in this machine's Hugging Face hub cache are referenced by no " + "project file, and how many gigabytes those account for. Derives each cached " + "model's identifier from its hub directory name and searches the given roots " + "(default ~/IdeaProjects) for any occurrence, case-insensitively. Ask before " + "suggesting a model cache be cleared, or to find what an old experiment left " + "behind. Read the `search_complete` field before quoting any verdict: when " + "the search could not be exhaustive -- a root that does not exist, a file cap " + "reached, a subtree that could not be read -- every model is reported " + "`unknown` rather than `unreferenced`, because absence of evidence is only " + "evidence of absence if the search actually ran. `unreferenced` is preview " + "evidence, not authorization: a model can be named in a notebook output, a " + "container image, or a repository outside these roots. Read-only; deletes " + "nothing and downloads nothing."), + "inputSchema": { + "type": "object", + "properties": { + "roots": { + "type": "array", "items": {"type": "string"}, "maxItems": MAX_HF_ROOTS, + "description": ("Directories to search for references. Omit for the " + "default. Naming a root that does not exist withholds " + "every verdict rather than producing false orphans."), + }, + "max_files": {"type": "integer", "minimum": 1000, "maximum": 2000000, + "default": 200000, + "description": ("Stop after this many files. Hitting the cap " + "marks the search incomplete.")}, + "limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 20, + "description": "Max models returned, largest first. Truncation is always reported."}, + }, + "additionalProperties": False, + }, + "annotations": {"title": "Hugging Face cache — models nothing here names", **READ_ONLY}, + "handler": tool_hf_orphans, + }, + { + "name": "mcp_hygiene", + "title": "MCP config hygiene — servers that cannot start", + "description": ( + "Registered MCP servers on this machine that cannot run: `dead` (the command " + "does not resolve, or a script argument points at a path that is gone), " + "`duplicate` (same command and args as another entry), `manual-review` (an " + "`env` block is present), `unknown` (no command, or PATH was unusable so the " + "check could not be made). Reads ~/.claude.json, the Claude Desktop configs, " + "and ~/.mcp.json, including servers nested under per-project blocks. Useful " + "when a tool an agent expects is silently absent, or before pruning years of " + "accumulated entries. `env` is reported only as a key count -- values and key " + "names are never read into the output. Whether a server is actually *used* is " + "not judged. Read-only: this never edits a config, disables a server, or " + "starts one."), + "inputSchema": { + "type": "object", + "properties": { + "status": {"type": "string", "enum": list(MCP_HYGIENE_STATUSES), + "description": "Only findings with this status."}, + "limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 25, + "description": "Max findings returned. Truncation is always reported."}, + }, + "additionalProperties": False, + }, + "annotations": {"title": "MCP config hygiene — servers that cannot start", **READ_ONLY}, + "handler": tool_mcp_hygiene, + }, { "name": "system_scan_summary", "title": "System scan summary — storage & security", @@ -465,7 +619,8 @@ def _enum_arg(args: dict, name: str, allowed: tuple, default): # refactor -- is unreachable rather than merely unlisted. Failing closed is the # point: "we simply never wrote a destructive tool" is an intention, and this # turns it into a mechanism. -EXPOSED_TOOL_NAMES = frozenset({"scree_report", "friction_scan", "system_scan_summary"}) +EXPOSED_TOOL_NAMES = frozenset({"scree_report", "friction_scan", "hf_orphans", + "mcp_hygiene", "system_scan_summary"}) def contract_allows(tool: dict) -> bool: diff --git a/scripts/mcpaudit.py b/scripts/mcpaudit.py new file mode 100644 index 0000000..049a28b --- /dev/null +++ b/scripts/mcpaudit.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Mcpaudit, Modore's MCP configuration hygiene check: which registered servers cannot run. + +MCP config accumulates the way autorun entries always have. A server is added +for one experiment, its checkout is deleted or renamed months later, and the +entry stays -- pointing at a command that is gone, duplicated under a second +name, or carrying an `env` block nobody remembers the contents of. Nothing +reports this: the client silently fails to start the server, and the operator +learns about it as "the tool just isn't there". + +This module reads the config files and says which entries cannot start, on the +same terms as every other Modore verdict: deterministic, metadata-only, and +strictly read-only. Ported from decant's `MCPHygiene.swift` (absorbed 2026-08). + +Two deliberate deviations from the original: +- decant anonymised every server to `server#N`. Modore names them. A hygiene + report the operator cannot act on is not a hygiene report, and a server name + is configuration metadata of the same kind scree already emits (workspace + paths, repository names). What stays hidden is what was sensitive in the + first place: `env` is reported as a key *count*, never as keys or values. +- a verdict that depends on PATH is withheld when PATH is unusable, rather + than reported as `dead`. This is the same fail-safe rule hfscan applies to + its search: a check that could not run has not established anything. + +Privacy contract (metadata-only output): +- `env` values and key names are never read into the output -- only whether a + block exists and how many keys it has; +- absolute paths are masked through `scree.mask_text` (home path, and the key + and token patterns it already covers, in case a config embeds one in an arg); +- writes nothing, starts nothing; all output goes to stdout. This module has no + code path that disables, edits, or removes a server. + +Judgment limits (preview-grade evidence, not a removal authorization): +- `dead` means the command does not resolve on this machine right now -- a + server behind a version manager, a not-yet-built checkout, or a volume that + is merely unmounted all look the same from here; +- `duplicate` compares command and args only; two entries that differ solely + by `env` are genuinely different servers and are reported as duplicates; +- whether a server is actually *used* is not judged. That needs session-log + cross-referencing, which this module does not do. +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from pathlib import Path +from typing import Any, Optional + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + # Python isolated mode (-I, which CI and the app's runner both use) + # intentionally omits the script directory. Import only the sibling + # helper from this resolved, repository-controlled directory. + sys.path.insert(0, str(SCRIPT_DIR)) + +from scree import mask_text # noqa: E402 + +CONFIG_CANDIDATES = ( + ".claude.json", + "Library/Application Support/Claude/claude_desktop_config.json", + ".config/claude/claude_desktop_config.json", + ".mcp.json", +) + +# Arguments that look like a local file the server needs in order to start. +PATH_LIKE_SUFFIXES = frozenset({".js", ".mjs", ".cjs", ".ts", ".py", ".rb", + ".jar", ".sh", ".phar"}) + +STATUS_ORDER = ("dead", "unknown", "duplicate", "manual-review") + +MAX_CONFIG_BYTES = 8 * 1024 * 1024 + + +# --------------------------------------------------------------------------- +# Config discovery and parsing +# --------------------------------------------------------------------------- + +def discover_configs(home: Path) -> list[Path]: + return [home / name for name in CONFIG_CANDIDATES if (home / name).is_file()] + + +def collect_servers(node: Any, config: Path, out: list[dict]) -> None: + """Every `mcpServers` block anywhere in the document. + + Claude's `~/.claude.json` nests per-project blocks under a `projects` map, + so a flat read of the root would miss most of the machine's servers. + """ + if isinstance(node, dict): + block = node.get("mcpServers") + if isinstance(block, dict): + for name, body in block.items(): + if isinstance(body, dict): + out.append(_entry(config, str(name), body)) + for value in node.values(): + collect_servers(value, config, out) + elif isinstance(node, list): + for value in node: + collect_servers(value, config, out) + + +def _entry(config: Path, name: str, body: dict) -> dict: + command = body.get("command") + args = body.get("args") + env = body.get("env") + return { + "config": config, + "name": name, + "command": command if isinstance(command, str) else None, + "args": [a for a in args if isinstance(a, str)] if isinstance(args, list) else [], + "env_key_count": len(env) if isinstance(env, dict) else 0, + "transport": "http" if isinstance(body.get("url"), str) else "stdio", + "url_only": isinstance(body.get("url"), str) and not isinstance(command, str), + } + + +def parse_config(path: Path) -> tuple[list[dict], Optional[str]]: + try: + if path.stat().st_size > MAX_CONFIG_BYTES: + return [], "config-too-large" + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError, UnicodeDecodeError) as exc: + return [], f"unreadable: {type(exc).__name__}" + entries: list[dict] = [] + collect_servers(document, path, entries) + return entries, None + + +# --------------------------------------------------------------------------- +# Resolution +# --------------------------------------------------------------------------- + +def path_entries(env: Optional[dict] = None) -> list[str]: + env = os.environ if env is None else env + raw = env.get("PATH") or "" + return [p for p in raw.split(os.pathsep) if p] + + +def command_resolves(command: str, search_path: list[str]) -> Optional[bool]: + """True / False / None, where None means 'could not check'. + + None is the fail-safe answer: a bare command name cannot be judged missing + when there is no PATH to look in, and reporting it as dead would send the + operator to delete a working server. + """ + if "/" in command: + expanded = os.path.expanduser(command) + return os.path.isfile(expanded) and os.access(expanded, os.X_OK) + if not search_path: + return None + for directory in search_path: + candidate = os.path.join(directory, command) + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return True + return False + + +def missing_local_paths(args: list[str], config_dir: Path) -> list[str]: + missing: list[str] = [] + for arg in args: + if arg.startswith("-"): + continue + expanded = os.path.expanduser(arg) + suffix = os.path.splitext(expanded)[1].lower() + looks_local = expanded.startswith(("/", "./", "../", "~")) or suffix in PATH_LIKE_SUFFIXES + if not looks_local: + continue + resolved = expanded if os.path.isabs(expanded) else str(config_dir / expanded) + if not os.path.exists(resolved): + missing.append(resolved) + return missing + + +def signature(entry: dict) -> str: + return "\x1f".join([entry["command"] or ""] + entry["args"]) + + +def classify(entry: dict, duplicates: set, search_path: list[str]) -> Optional[dict]: + """One finding, or None when the entry looks healthy. + + Order matters: an entry that cannot start at all is reported as such, and + the softer observations (duplicate, env present) only apply to entries that + otherwise resolve. + """ + reasons: list[str] = [] + + if entry["url_only"]: + # A remote server has no local command to resolve. Reachability is a + # network question, and Modore does not make network calls. + return None + + command = (entry["command"] or "").strip() + if not command: + return _finding(entry, "unknown", ["missing-command"]) + + resolved = command_resolves(command, search_path) + if resolved is None: + return _finding(entry, "unknown", ["path-unavailable-cannot-check-command"]) + if resolved is False: + reasons.append("command-not-found") + + if missing_local_paths(entry["args"], entry["config"].parent): + reasons.append("local-path-missing") + + if reasons: + return _finding(entry, "dead", reasons) + if signature(entry) in duplicates: + return _finding(entry, "duplicate", ["same-command-and-args-as-another-server"]) + if entry["env_key_count"]: + return _finding(entry, "manual-review", ["env-present-not-read"]) + return None + + +def _finding(entry: dict, status: str, reasons: list[str]) -> dict: + return { + "config": entry["config"], + "server": entry["name"], + "command_kind": os.path.basename(entry["command"] or "") or "missing-command", + "transport": entry["transport"], + "env_key_count": entry["env_key_count"], + "status": status, + "reasons": sorted(set(reasons)), + } + + +# --------------------------------------------------------------------------- +# Report +# --------------------------------------------------------------------------- + +def build_report(home: Path, *, env: Optional[dict] = None) -> dict: + configs = discover_configs(home) + search_path = path_entries(env) + + entries: list[dict] = [] + config_notes: list[dict] = [] + for config in configs: + parsed, error = parse_config(config) + if error: + config_notes.append({"path": mask_text(str(config), home), "error": error}) + entries.extend(parsed) + + counts: dict[str, int] = {} + for entry in entries: + sig = signature(entry) + if sig.strip("\x1f"): + counts[sig] = counts.get(sig, 0) + 1 + duplicates = {sig for sig, n in counts.items() if n > 1} + + findings = [] + for entry in entries: + finding = classify(entry, duplicates, search_path) + if finding: + findings.append({ + **finding, + "config": mask_text(str(finding["config"]), home), + "server": mask_text(finding["server"], home), + }) + findings.sort(key=lambda f: (STATUS_ORDER.index(f["status"]), f["server"])) + + summary = {status: sum(1 for f in findings if f["status"] == status) + for status in STATUS_ORDER} + return { + "generated_at": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime()), + "configs": [mask_text(str(c), home) for c in configs], + "config_errors": config_notes, + "server_count": len(entries), + "path_available": bool(search_path), + "findings": findings, + "summary": {**summary, "healthy": len(entries) - len(findings)}, + "evidence": "preview", + "requires_revalidation": True, + } + + +STATUS_LABEL = { + "dead": "실행 불가", + "unknown": "판정보류", + "duplicate": "중복", + "manual-review": "직접 확인", +} + + +def render_report(report: dict, limit: int) -> str: + lines = ["MCP 설정 위생 점검 (읽기 전용)", ""] + + if not report["configs"]: + lines.append("MCP 설정 파일을 찾지 못했습니다.") + return "\n".join(lines) + + lines.append(f"설정 {len(report['configs'])}개 · 서버 {report['server_count']}개") + for config in report["configs"]: + lines.append(f" {config}") + for note in report["config_errors"]: + lines.append(f" ⚠ {note['path']}: {note['error']}") + lines.append("") + + if not report["path_available"]: + lines.append("⚠ PATH를 읽을 수 없어 명령 존재 여부를 확인하지 않았습니다. " + "이름만 적힌 명령은 '판정보류'로 남습니다.") + lines.append("") + + if not report["findings"]: + lines.append("위생 소견 없음. 그래도 서버를 지우기 전에는 직접 확인하십시오.") + return "\n".join(lines) + + summary = report["summary"] + lines.append(" · ".join(f"{STATUS_LABEL[s]} {summary[s]}" for s in STATUS_ORDER + if summary.get(s))) + lines.append(f"정상 {summary['healthy']}") + lines.append("") + + for finding in report["findings"][:limit]: + lines.append(f"[{STATUS_LABEL[finding['status']]}] {finding['server']} " + f"· {finding['command_kind']}") + lines.append(f" 설정: {finding['config']}") + lines.append(f" 근거: {', '.join(finding['reasons'])}") + if finding["env_key_count"]: + lines.append(f" env 키 {finding['env_key_count']}개 (값·키 이름은 읽지 않음)") + if len(report["findings"]) > limit: + lines.append(f"... 그 외 {len(report['findings']) - limit}건 생략") + + lines.append("") + lines.append("이 점검은 설정을 바꾸지 않았고 서버를 끄거나 지우지 않았습니다. " + "'실행 불가'는 지금 이 머신에서 명령이 풀리지 않는다는 뜻이지, " + "그 서버가 불필요하다는 판정이 아닙니다.") + return "\n".join(lines) + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser( + description="Check registered MCP servers for entries that cannot start.") + parser.add_argument("--json", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--limit", type=int, default=20, help=argparse.SUPPRESS) + parser.add_argument("--home", type=Path, default=Path.home(), help=argparse.SUPPRESS) + args = parser.parse_args(argv) + + report = build_report(args.home) + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2)) + else: + print(render_report(report, args.limit)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_smoke.py b/scripts/release_smoke.py index dd16310..0ea62db 100644 --- a/scripts/release_smoke.py +++ b/scripts/release_smoke.py @@ -355,6 +355,8 @@ def verify_tag_with_signer( "scripts/schedule.sh", "scripts/scree.py", "scripts/friction.py", + "scripts/hfscan.py", + "scripts/mcpaudit.py", "scripts/mcp_server.py", "scripts/idle_cpu.sh", "scripts/network_watch.sh", diff --git a/tests/test_hfscan.py b/tests/test_hfscan.py new file mode 100644 index 0000000..c4604c8 --- /dev/null +++ b/tests/test_hfscan.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""hfscan 계약 테스트. + +핵심은 fail-safe 반전이다. 원본(decant `ContextProbe.swift`)은 검색이 실행되지 +못한 모든 경우를 "미참조"로 보고했고, 그래서 `--projects` 오타 하나가 허브 캐시 +전체를 삭제 후보로 만들 수 있었다. 아래 테스트들은 그 실패 경로 하나하나가 +`unknown`으로 떨어지는지를 고정한다. +""" +import json +from pathlib import Path + +import pytest + +import hfscan + + +def _make_hub(tmp_path: Path, *names: str) -> Path: + hub = tmp_path / ".cache" / "huggingface" / "hub" + hub.mkdir(parents=True) + for name in names: + model = hub / name / "snapshots" / "abc123" + model.mkdir(parents=True) + (model / "config.json").write_text('{"model_type": "test"}', encoding="utf-8") + return hub + + +def _report(tmp_path: Path, roots, **kwargs) -> dict: + return hfscan.build_report(tmp_path, [Path(r) for r in roots], env={}, **kwargs) + + +# --------------------------------------------------------------------------- +# 토큰 유도 +# --------------------------------------------------------------------------- + +def test_hub_dir_name_yields_both_the_slug_and_the_bare_model_name(): + assert hfscan.hub_tokens("models--Qwen--Qwen2.5-Coder-1.5B-Instruct") == [ + "Qwen/Qwen2.5-Coder-1.5B-Instruct", "Qwen2.5-Coder-1.5B-Instruct"] + + +def test_a_name_that_is_not_a_hub_dir_is_used_verbatim(): + assert hfscan.hub_tokens("datasets--foo") == ["datasets--foo"] + + +def test_adversarial_directory_names_are_data_not_shell(): + """decant pinned this after a shell-injection review; here there is no + shell at all, so the name simply survives as a literal token.""" + name = "models--evil$(touch PWNED)--x`whoami`" + tokens = hfscan.hub_tokens(name) + assert "evil$(touch PWNED)/x`whoami`" in tokens + + +# --------------------------------------------------------------------------- +# fail-safe 반전 — 이 모듈의 존재 이유 +# --------------------------------------------------------------------------- + +def test_a_mistyped_search_root_withholds_every_verdict(tmp_path): + """원본의 치명 결함: 루트가 없으면 전부 orphan이었다.""" + _make_hub(tmp_path, "models--Qwen--Qwen2.5-Coder-1.5B-Instruct") + report = _report(tmp_path, [tmp_path / "IdeaProjectsTYPO"]) + + assert report["search"]["complete"] is False + assert "search-root-missing" in report["search"]["incomplete_reasons"] + assert [m["verdict"] for m in report["models"]] == ["unknown"] + assert report["summary"]["unreferenced"] == 0 + + +def test_a_search_that_read_nothing_withholds_every_verdict(tmp_path): + _make_hub(tmp_path, "models--Qwen--Q1") + empty_root = tmp_path / "empty" + empty_root.mkdir() + report = _report(tmp_path, [empty_root]) + + assert report["search"]["complete"] is False + assert "no-files-read" in report["search"]["incomplete_reasons"] + assert [m["verdict"] for m in report["models"]] == ["unknown"] + + +def test_a_truncated_search_withholds_every_verdict(tmp_path): + _make_hub(tmp_path, "models--Qwen--Q1") + root = tmp_path / "proj" + root.mkdir() + for i in range(5): + (root / f"f{i}.py").write_text("print('x')", encoding="utf-8") + + report = _report(tmp_path, [root], max_files=2) + assert report["search"]["truncated"] is True + assert "file-cap-reached" in report["search"]["incomplete_reasons"] + assert [m["verdict"] for m in report["models"]] == ["unknown"] + + +def test_an_unreadable_subtree_withholds_every_verdict(tmp_path): + _make_hub(tmp_path, "models--Qwen--Q1") + root = tmp_path / "proj" + (root / "open").mkdir(parents=True) + (root / "open" / "a.py").write_text("x = 1", encoding="utf-8") + locked = root / "locked" + locked.mkdir() + (locked / "b.py").write_text("x = 2", encoding="utf-8") + locked.chmod(0o000) + try: + report = _report(tmp_path, [root]) + if report["search"]["dirs_unreadable"] == 0: + pytest.skip("running as a user that can read a 0o000 directory") + assert report["search"]["complete"] is False + assert "tree-partially-unreadable" in report["search"]["incomplete_reasons"] + assert [m["verdict"] for m in report["models"]] == ["unknown"] + finally: + locked.chmod(0o700) + + +def test_widening_the_verdict_takes_an_explicit_flag(tmp_path): + """탈출구는 존재하되 오타로는 열리지 않는다.""" + _make_hub(tmp_path, "models--Qwen--Q1") + root = tmp_path / "proj" + root.mkdir() + (root / "a.py").write_text("import torch", encoding="utf-8") + + guarded = _report(tmp_path, [root, tmp_path / "gone"]) + assert [m["verdict"] for m in guarded["models"]] == ["unknown"] + + widened = _report(tmp_path, [root, tmp_path / "gone"], allow_missing_roots=True) + assert widened["search"]["complete"] is True + assert [m["verdict"] for m in widened["models"]] == ["unreferenced"] + + +# --------------------------------------------------------------------------- +# 판정 자체 +# --------------------------------------------------------------------------- + +def test_a_model_named_in_a_project_file_is_kept(tmp_path): + _make_hub(tmp_path, "models--Qwen--Qwen2.5-Coder-1.5B-Instruct") + root = tmp_path / "proj" + root.mkdir() + (root / "load.py").write_text( + 'model = "Qwen/Qwen2.5-Coder-1.5B-Instruct"', encoding="utf-8") + + report = _report(tmp_path, [root]) + assert report["search"]["complete"] is True + model = report["models"][0] + assert model["verdict"] == "referenced" + assert model["referenced_by"] + + +def test_matching_is_case_insensitive_because_over_catching_keeps_a_model(tmp_path): + _make_hub(tmp_path, "models--Qwen--Qwen2.5-Coder") + root = tmp_path / "proj" + root.mkdir() + (root / "notes.md").write_text("we tried qwen/QWEN2.5-coder here", encoding="utf-8") + + assert _report(tmp_path, [root])["models"][0]["verdict"] == "referenced" + + +def test_a_completed_search_that_found_nothing_reports_unreferenced(tmp_path): + _make_hub(tmp_path, "models--Qwen--Q1") + root = tmp_path / "proj" + root.mkdir() + (root / "a.py").write_text("import numpy", encoding="utf-8") + + report = _report(tmp_path, [root]) + assert report["search"]["complete"] is True + assert report["models"][0]["verdict"] == "unreferenced" + assert report["models"][0]["reason"] == "no-occurrence-in-completed-search" + + +def test_an_absent_hub_is_not_an_error(tmp_path): + report = _report(tmp_path, [tmp_path]) + assert report["hub"]["exists"] is False + assert report["models"] == [] + + +# --------------------------------------------------------------------------- +# 계약: 증거 등급, 프라이버시, 무쓰기 +# --------------------------------------------------------------------------- + +def test_every_report_is_labelled_preview_evidence(tmp_path): + _make_hub(tmp_path, "models--Qwen--Q1") + root = tmp_path / "proj" + root.mkdir() + (root / "a.py").write_text("x = 1", encoding="utf-8") + + report = _report(tmp_path, [root]) + assert report["evidence"] == "preview" + assert report["requires_revalidation"] is True + + +def test_emitted_paths_are_masked_through_the_home_path(tmp_path): + _make_hub(tmp_path, "models--Qwen--Q1") + root = tmp_path / "proj" + root.mkdir() + (root / "a.py").write_text('name = "Qwen/Q1"', encoding="utf-8") + + report = _report(tmp_path, [root]) + referenced = report["models"][0]["referenced_by"][0] + assert referenced.startswith("~/") + assert str(tmp_path) not in json.dumps(report, ensure_ascii=False) + + +def test_file_contents_are_never_emitted(tmp_path): + _make_hub(tmp_path, "models--Qwen--Q1") + root = tmp_path / "proj" + root.mkdir() + (root / "a.py").write_text('SECRET = "hunter2"\nname = "Qwen/Q1"', encoding="utf-8") + + assert "hunter2" not in json.dumps(_report(tmp_path, [root]), ensure_ascii=False) + + +def test_the_module_never_writes_and_never_deletes(): + source = Path(hfscan.__file__).read_text(encoding="utf-8") + for forbidden in ("shutil.rmtree", "os.remove", "os.unlink", "os.rmdir", + "write_text", "write_bytes", "subprocess", "os.system", + "shell=True"): + assert forbidden not in source, f"hfscan must not use {forbidden}" + + +def test_the_renderer_states_that_unreferenced_is_not_authorization(tmp_path): + _make_hub(tmp_path, "models--Qwen--Q1") + root = tmp_path / "proj" + root.mkdir() + (root / "a.py").write_text("x = 1", encoding="utf-8") + + text = hfscan.render_report(_report(tmp_path, [root]), 20) + assert "삭제 승인이 아니라" in text + + +def test_the_renderer_explains_an_incomplete_search_instead_of_judging(tmp_path): + _make_hub(tmp_path, "models--Qwen--Q1") + text = hfscan.render_report(_report(tmp_path, [tmp_path / "gone"]), 20) + assert "검색이 완결되지 않아" in text + assert "미참조" in text # 표시는 되되 판정으로는 쓰이지 않는다 diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index e07f725..5255647 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -23,8 +23,9 @@ def _payload(result: dict) -> dict: # 읽기 전용 경계 — 이 표면의 존재 이유 # --------------------------------------------------------------------------- -def test_only_three_read_only_tools_are_exposed(): - assert sorted(mcp_server.HANDLERS) == ["friction_scan", "scree_report", +def test_only_the_read_only_judgment_tools_are_exposed(): + assert sorted(mcp_server.HANDLERS) == ["friction_scan", "hf_orphans", + "mcp_hygiene", "scree_report", "system_scan_summary"] @@ -50,7 +51,7 @@ def test_the_module_has_exactly_one_way_to_start_a_process(): assert forbidden not in source, f"MCP surface must not use {forbidden}" -def test_no_tool_can_run_anything_but_the_two_judgment_scripts(monkeypatch, tmp_path): +def test_no_tool_can_run_anything_but_the_judgment_scripts(monkeypatch, tmp_path): """Exercised, not inspected: every tool is called and the real spawn point is recorded. cleanup.sh, scanner.sh, and scree's content-reading `preserve` subcommand must never appear in an argument vector.""" @@ -75,7 +76,7 @@ def fake_run(argv, **kwargs): assert spawned, "expected the judgment scripts to be invoked" for argv in spawned: script = Path(argv[3]).name - assert script in ("scree.py", "friction.py"), argv + assert script in ("scree.py", "friction.py", "hfscan.py", "mcpaudit.py"), argv joined = " ".join(argv) for forbidden in ("cleanup", "scanner", "storage_watch", "schedule", "preserve", "--raw"): @@ -116,9 +117,11 @@ def test_a_tool_rejected_by_the_contract_is_unreachable_not_merely_unlisted(monk assert response["error"]["code"] == mcp_server.METHOD_NOT_FOUND -def test_the_two_judgment_scripts_are_the_declared_targets(): +def test_the_judgment_scripts_are_the_declared_targets(): assert mcp_server.SCREE.name == "scree.py" assert mcp_server.FRICTION.name == "friction.py" + assert mcp_server.HFSCAN.name == "hfscan.py" + assert mcp_server.MCPAUDIT.name == "mcpaudit.py" def test_scan_summary_states_that_it_cannot_start_a_scan(tmp_path, monkeypatch): @@ -294,6 +297,7 @@ def test_initialize_echoes_a_supported_version_and_falls_back_otherwise(): def test_tools_list_declares_closed_input_schemas(): tools = mcp_server.handle_request("tools/list", {})["tools"] assert [t["name"] for t in tools] == ["scree_report", "friction_scan", + "hf_orphans", "mcp_hygiene", "system_scan_summary"] for tool in tools: assert tool["inputSchema"]["additionalProperties"] is False @@ -343,9 +347,70 @@ def test_cli_tools_dump_is_the_registered_surface(capsys): assert mcp_server.main(["--tools"]) == 0 dumped = json.loads(capsys.readouterr().out) assert [t["name"] for t in dumped["exposed"]] == ["scree_report", "friction_scan", + "hf_orphans", "mcp_hygiene", "system_scan_summary"] assert dumped["rejected"] == [] def test_cli_rejects_unknown_arguments(capsys): assert mcp_server.main(["--run-cleanup"]) == 2 + + +# --------------------------------------------------------------------------- +# 흡수한 판정 두 개 (decant → hfscan / mcpaudit) +# --------------------------------------------------------------------------- + +def test_a_search_root_cannot_be_smuggled_in_as_an_option(): + """`roots`는 이 표면에서 호출자가 경로를 넘기는 유일한 자리다.""" + for bad in (["--home"], ["-x"], [""], "not-a-list", [1]): + result = _call("hf_orphans", {"roots": bad}) + assert result.get("isError"), bad + + too_many = [f"/tmp/r{i}" for i in range(mcp_server.MAX_HF_ROOTS + 1)] + assert _call("hf_orphans", {"roots": too_many}).get("isError") + + +def test_hf_orphans_surfaces_a_withheld_verdict_at_the_top_level(monkeypatch): + """불완전한 검색은 조용히 '미참조 0건'으로 보이면 안 된다.""" + incomplete = { + "evidence": "preview", "requires_revalidation": True, + "hub": {"path": "~/.cache/huggingface/hub", "exists": True, + "model_count": 1, "total_bytes": 10}, + "search": {"complete": False, "incomplete_reasons": ["search-root-missing"]}, + "summary": {"referenced": 0, "unreferenced": 0, "unknown": 1, + "unreferenced_bytes": 0}, + "models": [{"name": "models--a--b", "verdict": "unknown", "size_bytes": 10}], + } + monkeypatch.setattr(mcp_server, "_run_json", + lambda script, arguments, timeout: incomplete) + payload = _payload(_call("hf_orphans", {})) + assert payload["search_complete"] is False + assert payload["verdicts_withheld"] is True + assert payload["models"][0]["verdict"] == "unknown" + + +def test_mcp_hygiene_filters_by_status_and_never_forwards_env(monkeypatch): + report = { + "evidence": "preview", "requires_revalidation": True, + "configs": ["~/.claude.json"], "config_errors": [], "server_count": 2, + "path_available": True, + "summary": {"dead": 1, "unknown": 0, "duplicate": 0, + "manual-review": 1, "healthy": 0}, + "findings": [ + {"server": "gone", "status": "dead", "reasons": ["command-not-found"], + "config": "~/.claude.json", "command_kind": "node", "env_key_count": 0}, + {"server": "keyed", "status": "manual-review", + "reasons": ["env-present-not-read"], "config": "~/.claude.json", + "command_kind": "node", "env_key_count": 3}, + ], + } + monkeypatch.setattr(mcp_server, "_run_json", + lambda script, arguments, timeout: report) + + payload = _payload(_call("mcp_hygiene", {"status": "dead"})) + assert [f["server"] for f in payload["findings"]] == ["gone"] + + everything = json.dumps(_payload(_call("mcp_hygiene", {})), ensure_ascii=False) + assert "env_key_count" in everything + for leaked in ("ANTHROPIC_API_KEY", "sk-ant", "env_values"): + assert leaked not in everything diff --git a/tests/test_mcpaudit.py b/tests/test_mcpaudit.py new file mode 100644 index 0000000..f183eba --- /dev/null +++ b/tests/test_mcpaudit.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""mcpaudit 계약 테스트: 읽기 전용, env 무접촉, 그리고 확인 못 한 것은 단정하지 않기.""" +import json +import os +import stat +from pathlib import Path + +import mcpaudit + + +def _write_config(home: Path, servers: dict, *, name: str = ".claude.json", + wrapper: str = "root") -> Path: + path = home / name + path.parent.mkdir(parents=True, exist_ok=True) + block = {"mcpServers": servers} + document = block if wrapper == "root" else {"projects": {"/some/proj": block}} + path.write_text(json.dumps(document), encoding="utf-8") + return path + + +def _executable(home: Path, name: str) -> Path: + bindir = home / "bin" + bindir.mkdir(parents=True, exist_ok=True) + target = bindir / name + target.write_text("#!/bin/sh\n", encoding="utf-8") + target.chmod(target.stat().st_mode | stat.S_IXUSR) + return target + + +def _report(home: Path, path_dirs=()) -> dict: + env = {"PATH": os.pathsep.join(str(p) for p in path_dirs)} if path_dirs else {"PATH": ""} + return mcpaudit.build_report(home, env=env) + + +# --------------------------------------------------------------------------- +# 발견 +# --------------------------------------------------------------------------- + +def test_servers_nested_under_projects_are_found(tmp_path): + """~/.claude.json은 프로젝트별 블록 아래 서버를 중첩한다. 루트만 읽으면 + 이 머신 서버 대부분을 놓친다.""" + _write_config(tmp_path, {"nested": {"command": "node"}}, wrapper="projects") + report = _report(tmp_path, [_executable(tmp_path, "node").parent]) + assert report["server_count"] == 1 + + +def test_an_absent_config_set_is_not_an_error(tmp_path): + report = _report(tmp_path) + assert report["configs"] == [] + assert report["findings"] == [] + + +def test_an_unparseable_config_is_reported_not_swallowed(tmp_path): + (tmp_path / ".claude.json").write_text("{not json", encoding="utf-8") + report = _report(tmp_path) + assert report["config_errors"] + assert "unreadable" in report["config_errors"][0]["error"] + + +# --------------------------------------------------------------------------- +# 판정 +# --------------------------------------------------------------------------- + +def test_a_command_that_does_not_resolve_is_dead(tmp_path): + _write_config(tmp_path, {"gone": {"command": "definitely-not-installed"}}) + report = _report(tmp_path, [_executable(tmp_path, "node").parent]) + assert [(f["server"], f["status"]) for f in report["findings"]] == [("gone", "dead")] + assert "command-not-found" in report["findings"][0]["reasons"] + + +def test_a_missing_script_argument_is_dead(tmp_path): + bindir = _executable(tmp_path, "node").parent + _write_config(tmp_path, {"stale": {"command": "node", + "args": [str(tmp_path / "deleted" / "server.mjs")]}}) + report = _report(tmp_path, [bindir]) + assert report["findings"][0]["status"] == "dead" + assert "local-path-missing" in report["findings"][0]["reasons"] + + +def test_two_entries_with_the_same_command_and_args_are_duplicates(tmp_path): + bindir = _executable(tmp_path, "node").parent + script = tmp_path / "s.mjs" + script.write_text("", encoding="utf-8") + _write_config(tmp_path, {"a": {"command": "node", "args": [str(script)]}, + "b": {"command": "node", "args": [str(script)]}}) + report = _report(tmp_path, [bindir]) + assert {f["status"] for f in report["findings"]} == {"duplicate"} + assert {f["server"] for f in report["findings"]} == {"a", "b"} + + +def test_a_healthy_server_produces_no_finding(tmp_path): + bindir = _executable(tmp_path, "node").parent + script = tmp_path / "s.mjs" + script.write_text("", encoding="utf-8") + _write_config(tmp_path, {"fine": {"command": "node", "args": [str(script)]}}) + report = _report(tmp_path, [bindir]) + assert report["findings"] == [] + assert report["summary"]["healthy"] == 1 + + +def test_a_remote_server_is_not_judged_dead_for_having_no_command(tmp_path): + """URL 서버의 도달성은 네트워크 질문이고, Modore는 네트워크를 만지지 않는다.""" + _write_config(tmp_path, {"remote": {"url": "https://example.invalid/mcp"}}) + assert _report(tmp_path, [tmp_path])["findings"] == [] + + +# --------------------------------------------------------------------------- +# fail-safe — 확인하지 못한 것은 단정하지 않는다 +# --------------------------------------------------------------------------- + +def test_without_a_usable_path_a_bare_command_is_withheld_not_called_dead(tmp_path): + _write_config(tmp_path, {"maybe": {"command": "node"}}) + report = mcpaudit.build_report(tmp_path, env={"PATH": ""}) + finding = report["findings"][0] + assert finding["status"] == "unknown" + assert finding["reasons"] == ["path-unavailable-cannot-check-command"] + assert report["path_available"] is False + + +def test_an_absolute_command_is_still_checkable_without_path(tmp_path): + target = _executable(tmp_path, "srv") + _write_config(tmp_path, {"abs": {"command": str(target)}}) + assert mcpaudit.build_report(tmp_path, env={"PATH": ""})["findings"] == [] + + +def test_a_missing_command_key_is_unknown_not_dead(tmp_path): + _write_config(tmp_path, {"empty": {"args": ["x"]}}) + report = _report(tmp_path, [tmp_path]) + assert report["findings"][0]["status"] == "unknown" + assert report["findings"][0]["reasons"] == ["missing-command"] + + +# --------------------------------------------------------------------------- +# 프라이버시 계약 +# --------------------------------------------------------------------------- + +def test_env_values_and_key_names_are_never_emitted(tmp_path): + bindir = _executable(tmp_path, "node").parent + _write_config(tmp_path, {"secretive": { + "command": "node", + "env": {"ANTHROPIC_API_KEY": "sk-ant-do-not-leak", "DB_URL": "postgres://u:p@h/db"}, + }}) + report = _report(tmp_path, [bindir]) + blob = json.dumps(report, ensure_ascii=False) + + assert "sk-ant-do-not-leak" not in blob + assert "postgres://u:p@h/db" not in blob + assert "ANTHROPIC_API_KEY" not in blob + assert "DB_URL" not in blob + + finding = report["findings"][0] + assert finding["status"] == "manual-review" + assert finding["env_key_count"] == 2 + + +def test_home_paths_are_masked(tmp_path): + _write_config(tmp_path, {"gone": {"command": "definitely-not-installed"}}) + report = _report(tmp_path, [tmp_path]) + assert report["configs"] == ["~/.claude.json"] + assert str(tmp_path) not in json.dumps(report, ensure_ascii=False) + + +def test_the_module_cannot_modify_disable_or_start_anything(): + source = Path(mcpaudit.__file__).read_text(encoding="utf-8") + for forbidden in ("subprocess", "os.system", "os.remove", "os.unlink", + "shutil", "write_text", "write_bytes", "shell=True", + "urllib", "socket.", "urlopen", "http.client", "requests."): + assert forbidden not in source, f"mcpaudit must not use {forbidden}" + + +def test_every_report_is_labelled_preview_evidence(tmp_path): + _write_config(tmp_path, {"gone": {"command": "definitely-not-installed"}}) + report = _report(tmp_path, [tmp_path]) + assert report["evidence"] == "preview" + assert report["requires_revalidation"] is True + + +def test_the_renderer_states_that_nothing_was_changed(tmp_path): + _write_config(tmp_path, {"gone": {"command": "definitely-not-installed"}}) + text = mcpaudit.render_report(_report(tmp_path, [tmp_path]), 20) + assert "설정을 바꾸지 않았고" in text + assert "불필요하다는 판정이 아닙니다" in text diff --git a/tests/test_service_contracts.py b/tests/test_service_contracts.py index 51c484e..55975d5 100644 --- a/tests/test_service_contracts.py +++ b/tests/test_service_contracts.py @@ -639,13 +639,16 @@ def test_bundled_app_runtime_includes_every_macos_script(project_root): "scripts/_jsonutil.py", "scripts/rule_engine.py", "scripts/scanner_helper.py", - # CLI-and-MCP-only surfaces: the app has no view that runs either, so - # shipping them inside the signed bundle would add unreachable code to - # the sealed runtime. friction.py belongs here only while it has no - # Swift caller -- give it one and it must move into RUNTIME_FILES, the - # same way scree.py did. mcp_server.py is a stdio server that a separate - # MCP client launches from a checkout; the app never spawns it. + # CLI-and-MCP-only surfaces: the app has no view that runs any of them, + # so shipping them inside the signed bundle would add unreachable code + # to the sealed runtime. friction.py, hfscan.py, and mcpaudit.py belong + # here only while they have no Swift caller -- give one a card and it + # must move into RUNTIME_FILES, the same way scree.py did. + # mcp_server.py is a stdio server that a separate MCP client launches + # from a checkout; the app never spawns it. "scripts/friction.py", + "scripts/hfscan.py", + "scripts/mcpaudit.py", "scripts/mcp_server.py", } non_shell_expected = { From 94590594a4bfcc4797cb45bfd33a478eabdf215a Mon Sep 17 00:00:00 2001 From: Heznpc Date: Wed, 19 Aug 2026 20:08:34 +0900 Subject: [PATCH 2/3] Absorb canary's file-access index; gate Mothball's destructive API Two pieces of the consolidation that had no equivalent here. canary's `get_file_access` inverts the evidence scree already reads: for each path, how many reads, writes, and shell references it received, from how many sessions, and when last. Rule surfaces -- CLAUDE.md, AGENTS.md, settings.json, anything under ~/.claude or ~/.codex -- sort first and are the default view, because a silently edited rule file is the case this exists for. The taxonomy is carried over verbatim. Its content contract is stricter than the original's. canary attached a 200-character excerpt of the shell command to every row; that excerpt is command content, so it is dropped. A path extracted from a command is metadata about which file was touched -- the command that touched it is not. Assistant text, tool results, and non-path tool inputs are discarded in the same pass that reads them, and nested subagent transcripts stay unopened, matching scree's collector. Two things the naive port got wrong, both caught by running it: Paths are canonicalised before aggregation. The same file arrives as an absolute path from a tool input and as `~/...` from a shell command, so aggregating raw strings split one file across several rows -- 1,322 rows where there were 1,212 files, with ~/.claude/settings.json appearing twice. An inverted index that cannot put one file on one row is not doing its job. Dedupe is keyed on the tool_use block id, not on the path. Claude streams one assistant message as multiple lines sharing a message id, which is why dedupe is needed at all; keying it on the path also erased genuine repeat reads within a session, turning a count of touches into a count of sessions. Separately: absorbing Mothball absorbed a second deletion discipline. Modore destroys nothing without a preview-issued single-use token, an owner-only manifest, a remeasure at the boundary, and a receipt. MothballCore's `ArchiveOrchestrator.archive()` is careful on its own terms but is an in-process call taking no token, and the vendor dependency already compiles it into the binary -- only review stood between a future `try orchestrator.archive(...)` in a view action and a path the approval chain never sees. Two tests make that a mechanism: a Modore source naming ArchiveOrchestrator, Restorer, ArchiveRun, or trashItem must also carry the approval-token symbol, so a diff cannot bring one half without showing the other; and MothballService is pinned to the read-only scan-and-classify surface it actually uses. Wiring the archive path stays allowed -- wiring it around the token does not. Both guards were verified by injecting a violation and watching them fail. Verified: 505 tests pass, swift build clean, release_smoke clean. Live run -- 60 sessions, 1,876 paths, 208 rule surfaces; ~/.claude/settings.json shows 3 writes across 10 sessions, and no command text appears anywhere in the payload. --- CHANGELOG.md | 2 + README.md | 5 +- docs/ARCHITECTURE.md | 13 +- scripts/fileaccess.py | 386 ++++++++++++++++++++++++++++++++ scripts/mcp_server.py | 72 +++++- scripts/release_smoke.py | 1 + tests/test_fileaccess.py | 244 ++++++++++++++++++++ tests/test_mcp_server.py | 48 +++- tests/test_release_hardening.py | 77 +++++++ tests/test_service_contracts.py | 8 +- 10 files changed, 842 insertions(+), 14 deletions(-) create mode 100644 scripts/fileaccess.py create mode 100644 tests/test_fileaccess.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7049d7d..ac1b040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to this project are documented here. Format loosely follows ## [Unreleased] ### Added +- **Mothball's destructive API is now unreachable from Modore by mechanism, not by intention.** Absorbing Mothball absorbed two different deletion disciplines. Modore destroys nothing without a preview that issues a single-use 64-byte approval token, a 15-minute owner-only manifest, a remeasure at the destructive boundary, and a receipt. MothballCore's `ArchiveOrchestrator.archive()` is careful in its own right — it refuses `/` and `$HOME`, verifies the archive before touching the original, and moves to Trash rather than unlinking — but it is an *in-process* Swift call that takes no token and consumes no manifest, and the vendor dependency already compiles it into Modore's binary. Nothing but review stood between a future `try orchestrator.archive(…)` in a view action and a second deletion path the approval chain never sees. Two tests now pin the boundary: any Modore source file that names `ArchiveOrchestrator`, `Restorer`, `ArchiveRun`, or `trashItem` must also carry the approval-token symbol, so a diff cannot introduce one half without showing the other; and `MothballService` is pinned to the read-only scan-and-classify surface it actually uses. Wiring the archive path stays allowed — wiring it *around* the token does not. +- **File-access reverse index — `scripts/fileaccess.py`.** Absorbed from canary's `get_file_access` / `getFileAccessAggregates` (that repository is a frozen paper archive; this was the capability Modore had no equivalent for). Inverts the evidence scree already reads: for each path, how many reads, writes, and shell references it received, from how many sessions, and when last — with agent rule and config surfaces (CLAUDE.md, AGENTS.md, settings.json, anything under `~/.claude` or `~/.codex`) sorted first and returned by default, because a silently edited rule file is the case the view exists for. The rule-surface taxonomy is carried over verbatim. **The content contract is stricter than the original's**: canary attached a 200-character excerpt of the shell command to every row, and that excerpt is command content, so it is dropped here — a path extracted from a command is metadata about which file was touched, the command that touched it is not. Only tool names and paths are retained; assistant text, tool results, and non-path tool inputs are discarded in the same pass that reads them, and nested subagent transcripts are never opened, matching scree's collector. Paths are canonicalised before aggregation, so a file reached as `/Users/x/.claude/settings.json` from a tool input and as `~/.claude/settings.json` from a shell command is one row rather than several. Dedupe is keyed on the tool_use block id — Claude streams one assistant message as multiple lines sharing a message id, so a path-keyed dedupe would silently collapse a genuine second read. Exposed as the `file_access` MCP tool. - **Hugging Face cache audit — `scripts/hfscan.py`.** Absorbed from decant's `ContextProbe.swift` (that repository is being retired; this was one of two capabilities Modore did not already have). Derives each cached model's identifier from its hub directory name (`models--Qwen--Qwen2.5-Coder-1.5B-Instruct` → the `Qwen/…` slug and the bare leaf) and searches the given roots for any occurrence, case-insensitively, so that over-catching keeps a model rather than losing one. **The original's central defect is inverted here.** decant returned "unreferenced" whenever the search could not run — a missing search root, a failed grep, a mistyped `--projects` argument all produced the same answer as a genuinely exhaustive search that found nothing, so one typo could mark an entire hub cache safe to delete. Absence of evidence is only evidence of absence when the search actually happened: a root that does not exist, a file cap reached, or a subtree that could not be read now yields `unknown` for every model with the reason stated, and `--allow-missing-roots` / `--ignore-unreadable` widen the verdict only when the operator asks. Metadata-only: file contents are matched in memory and discarded, and the only paths emitted are the files that *do* reference a model, masked through `scree.mask_text`. - **MCP config hygiene — `scripts/mcpaudit.py`.** Absorbed from decant's `MCPHygiene.swift`. Reads `~/.claude.json` (including servers nested under per-project blocks), both Claude Desktop config locations, and `~/.mcp.json`, and reports entries that cannot start: `dead` (command does not resolve, or a script argument points at a path that is gone), `duplicate` (same command and args as another entry), `manual-review` (an `env` block is present), `unknown` (no command at all). Two deliberate deviations from the original: servers are named rather than anonymised to `server#N`, because a hygiene report the operator cannot act on is not one, and a server name is configuration metadata of the same kind scree already emits — while `env` is reported as a key *count*, never as keys or values. And a verdict that depends on PATH is withheld as `unknown` when PATH is unusable rather than reported as `dead`, the same fail-safe rule hfscan applies to its search. Read-only in the strong sense: the module has no code path that edits a config, disables a server, or starts one, pinned by a test. - **Two more read-only MCP tools — `hf_orphans`, `mcp_hygiene`.** The audits above, exposed through the existing surface on the same terms as `scree_report` and `friction_scan`: allowlisted by name, annotated read-only and non-destructive, forwarding what the judgment script prints rather than reimplementing it. `hf_orphans` restates `search_complete` as a top-level `verdicts_withheld` flag so an incomplete search cannot be misread as "no orphans found", and its `roots` argument — the one place a caller supplies a path — is bounded to 8 entries and screened so a value can never be read as an option by the script it is passed to. diff --git a/README.md b/README.md index 03b8272..5a0dad6 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ python3 scripts/mcp_server.py --tools # inspect the surface without speaking - `friction_scan` — the pushback taxonomy, filterable by store, category, and minimum severity. - `hf_orphans` — which models in the Hugging Face hub cache no project file names, and how many gigabytes those account for. Reports `search_complete`; when the search could not be exhaustive every model is `unknown`, never `unreferenced`. - `mcp_hygiene` — registered MCP servers that cannot start: dead command, missing script path, duplicate entry, or an `env` block worth a human look (reported as a key count, never as keys or values). +- `file_access` — the reverse index: which sessions touched which paths, with reads/writes/shell counts. Agent rule surfaces first, because a silently edited `CLAUDE.md` is the case it exists for. - `system_scan_summary` — the storage and security scan result *already on disk*, with its age, because a stale result read as current is the failure mode here. - **A thin layer, not a second implementation** — each tool runs one judgment script with `--json` and forwards what it prints, so the CLI, the Mac app, and the MCP surface cannot disagree about what is true. - **Read-only by contract, enforced at registration** — a tool is reachable only if it is on an explicit allowlist and annotated read-only and non-destructive; one added without a deliberate edit fails closed. Cleanup, deletion, and scan execution are deliberately absent. Modore gates destruction on an approval a human grants on screen; an agent-reachable bypass would not be a feature, it would be the end of that guarantee. Every result is fenced as untrusted data. @@ -73,6 +74,7 @@ A fan that will not stop, CPU/GPU load while idle, an unknown process, a strange - **Mac Edition — operator-friction scan**: `friction` classifies the turns where the operator pushed back on agent behaviour across Claude Code, Codex, Gemini CLI, and Claude Desktop transcripts — nine categories, severity 1-3, deterministic keyword/tone matching, user-authored turns only, quotes masked by default. - **Mac Edition — Hugging Face cache audit**: `hfscan` cross-references every cached model against the code on this machine and reports which ones nothing names. An incomplete search withholds the verdict instead of guessing. - **Mac Edition — MCP config hygiene**: `mcpaudit` reads the registered MCP servers and reports the entries that cannot start. It never edits a config, disables a server, or starts one. +- **Mac Edition — file-access reverse index**: `fileaccess` answers "which sessions touched this file, how often, and when last", rule surfaces first. Only paths and tool names are kept — the command a path came from is never emitted. - **Read-only MCP surface**: a zero-dependency stdio MCP server exposing scree, friction, the two audits above, and the existing storage/security scan summary to an agent mid-session. Judgment only — no cleanup, no deletion, no scan execution. - **Mac Edition scanner**: Bash + JXA collectors for macOS security context, launchd/login items, Gatekeeper/SIP/XProtect, network/listening ports, installed-app size, and developer-runtime incidents. Every collector reports `ok`, `permission_denied`, `unavailable`, `timed_out`, or `failed`; a missing required collector can never become a safe verdict. - **Mac Edition app**: the native SwiftUI app presents one incident judgment followed by evidence, likely impact, and approval-gated recovery; bounded local history keeps the judgment without storing raw commands or URLs. Browser automation is grouped into roots with PID, parent, elapsed time, channel, profile type, and a privacy-preserving controller label. @@ -244,7 +246,8 @@ modore/ │ ├── friction.py operator-pushback scan over the same session stores │ ├── hfscan.py Hugging Face hub cache: which models nothing here names │ ├── mcpaudit.py MCP config hygiene: registered servers that cannot start -│ ├── mcp_server.py read-only MCP surface (scree · friction · hfscan · mcpaudit · scan summary) +│ ├── fileaccess.py reverse index: which sessions touched which paths +│ ├── mcp_server.py read-only MCP surface (scree · friction · hfscan · mcpaudit · fileaccess · scan summary) │ ├── menu.ps1 Windows interactive menu │ ├── scanner.ps1 Windows scanner │ ├── monitor.ps1 Windows 5-min idle monitor diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 53befe2..821cf07 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -50,7 +50,7 @@ Every app build embeds an explicit runtime allowlist under `Contents/Resources/r mask-by-default Markdown export for records about to expire. There is no bulk-export path, and no cleanup recipe consumes scree output. -## Absorbed audits (hfscan, mcpaudit) +## Absorbed audits (hfscan, mcpaudit, fileaccess) - `scripts/hfscan.py` judges the Hugging Face hub cache: it derives each cached model's identifier from its hub directory name and searches the given roots for any occurrence. Absorbed from @@ -61,9 +61,16 @@ Every app build embeds an explicit runtime allowlist under `Contents/Resources/r - `scripts/mcpaudit.py` judges MCP configuration: which registered servers cannot start. Absorbed from decant's `MCPHygiene.swift`. `env` is reported as a key count only, and any verdict that depends on PATH is withheld when PATH is unusable — the same fail-safe rule as above. -- Both are metadata-only, write nothing, and start nothing. Neither ships inside the signed app +- `scripts/fileaccess.py` inverts scree's evidence to path → sessions: reads, writes, shell + references, session count, and last touch, with agent rule surfaces sorted first. Absorbed from + canary's `get_file_access`. Its content contract is *stricter* than the original's — canary + attached a 200-character excerpt of the shell command to every row, and that excerpt is command + content, so it is dropped. A path extracted from a command is metadata about which file was + touched; the command that touched it is not. Nested subagent transcripts stay unopened, matching + scree's collector. +- All three are metadata-only, write nothing, and start nothing. None ships inside the signed app bundle: they have no Swift caller, so they stay CLI-and-MCP surfaces, and a test pins that - giving either one a view means moving it into `RUNTIME_FILES`. + giving one a view means moving it into `RUNTIME_FILES`. ## Cleanup invariants diff --git a/scripts/fileaccess.py b/scripts/fileaccess.py new file mode 100644 index 0000000..4e0fc4f --- /dev/null +++ b/scripts/fileaccess.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Fileaccess, Modore's path-to-session reverse index: which sessions touched this file. + +scree answers "what did the agents leave behind" per store and per workspace. +This module inverts the same evidence to the question an operator actually asks +when something changed and nobody remembers doing it: *which sessions touched +this path, how often, and when last?* Rule surfaces -- CLAUDE.md, AGENTS.md, +settings.json, anything under ~/.claude or ~/.codex -- sort first, because a +silently edited rule file is the case this view exists for. + +Ported from canary's `get_file_access` / `getFileAccessAggregates` +(`lib/sessions/scan.ts`), which is being retired into a frozen paper archive. +The taxonomy of what counts as a rule surface is carried over verbatim. + +Content contract (stricter than the original, and not an exception to scree's): +- transcripts are streamed and tool-use blocks are decoded, but only the tool + NAME and the PATHS it names are retained. Assistant text, tool results, and + tool inputs other than paths are discarded in the same pass that reads them, + exactly as scree already decodes and discards message content; +- canary attached a 200-character excerpt of the shell command to every row. + That excerpt is command *content*, and it is dropped here. A path extracted + from a command is metadata about which file was touched; the command that + touched it is not; +- nested subagent transcripts are never opened, matching scree's collector; +- writes nothing; all output goes to stdout. + +Judgment limits (preview-grade evidence): +- paths inside shell commands are recovered by a heuristic regex, so a `bash` + count both over- and under-catches: a path mentioned but not touched counts, + and a path built from a variable does not; +- a file can have been changed by something no session records -- an editor, + another machine, a script run outside an agent. Absence from this index is + not evidence that no agent touched it, only that no *indexed* session did; +- tool names are recorded as the transcript spells them, so a renamed tool in a + future client version simply stops matching the read/write sets rather than + being reclassified. +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import time +from pathlib import Path +from typing import Iterator, Optional + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + # Python isolated mode (-I, which CI and the app's runner both use) + # intentionally omits the script directory. Import only the sibling + # collectors from this resolved, repository-controlled directory. + sys.path.insert(0, str(SCRIPT_DIR)) + +from scree import collect_claude, collect_codex, mask_text # noqa: E402 + +# Ported verbatim from canary's lib/sessions/types.ts. +RULE_BASENAMES = frozenset({ + "CLAUDE.md", "CLAUDE.local.md", "AGENTS.md", "AGENTS.override.md", + "GEMINI.md", "copilot-instructions.md", "settings.json", + "settings.local.json", "managed-settings.json", "config.toml", +}) +RULE_DIR_MARKERS = ("/.claude/", "/.codex/", "/.cursor/", "/.github/instructions/") + +READ_TOOLS = frozenset({"Read", "Glob", "Grep", "NotebookRead"}) +WRITE_TOOLS = frozenset({"Write", "Edit", "MultiEdit", "NotebookEdit"}) +CODEX_COMMAND_TOOLS = frozenset({"exec_command", "shell"}) + +# Absolute or home-relative paths inside a shell command. URLs (which contain +# "//") are excluded; the cap keeps one pathological command from dominating. +_COMMAND_PATH_RE = re.compile(r"""(?:^|[\s"'=(:])((?:~|/)[A-Za-z0-9_.@\-/]{2,})""") +_TRAILING_PUNCT_RE = re.compile(r"""[).,;:'"]+$""") +COMMAND_PATH_CAP = 8 + +DEFAULT_MAX_SESSIONS = 400 +MAX_LINES_PER_SESSION = 40_000 + + +def canonical_path(path: str, home: Path) -> str: + """One row per file, whatever spelling the transcript used. + + The same file reaches this index as `/Users/x/.claude/settings.json` from a + tool input and as `~/.claude/settings.json` from a shell command, and with + or without a trailing slash. Aggregating on the raw string splits one file + into several rows, which defeats the point of an inverted index -- so the + spelling is normalised here, before counting, and masked only on the way + out. + """ + text = path.strip() + if not text: + return text + if text == "~": + text = str(home) + elif text.startswith("~/"): + text = str(home) + text[1:] + text = os.path.normpath(text) + return text + + +def is_rule_surface(path: str) -> bool: + """A path whose silent modification is worth investigating.""" + if os.path.basename(path) in RULE_BASENAMES: + return True + return any(marker in path for marker in RULE_DIR_MARKERS) + + +def paths_in_command(command: str, cap: int = COMMAND_PATH_CAP) -> list[str]: + found: list[str] = [] + for match in _COMMAND_PATH_RE.finditer(command): + candidate = match.group(1) + if not candidate or "//" in candidate: + continue + cleaned = _TRAILING_PUNCT_RE.sub("", candidate) + if cleaned and cleaned not in found: + found.append(cleaned) + if len(found) >= cap: + break + return found + + +# --------------------------------------------------------------------------- +# Per-transcript extraction +# --------------------------------------------------------------------------- + +def _claude_entries(line: dict) -> Iterator[tuple[str, str, str, str]]: + """(op, tool, path, dedupe_key) for one Claude JSONL line. + + Claude streams one assistant message as MULTIPLE lines sharing a message + id, each carrying a different content block, so the same tool_use can be + read more than once in a single pass. Dedupe therefore belongs at the block + level, keyed on the block id -- deduping on the path instead would collapse + a genuine second read of the same file, which is exactly the repetition + this index exists to count. + """ + message = line.get("message") + content = message.get("content") if isinstance(message, dict) else None + if not isinstance(content, list): + return + message_id = message.get("id") if isinstance(message.get("id"), str) else "" + for index, block in enumerate(content): + if not isinstance(block, dict) or block.get("type") != "tool_use": + continue + name = block.get("name") + payload = block.get("input") + if not isinstance(name, str) or not isinstance(payload, dict): + continue + block_id = block.get("id") + # An id-less block still needs a stable key, and the input is what makes + # one call different from the next. + key = block_id if isinstance(block_id, str) and block_id else ( + f"{message_id}|{index}|{name}|{json.dumps(payload, sort_keys=True, default=str)}") + target = (payload.get("file_path") or payload.get("notebook_path") + or payload.get("path")) + if name in READ_TOOLS: + if isinstance(target, str) and target: + yield ("read", name, target, key) + elif name in WRITE_TOOLS: + if isinstance(target, str) and target: + yield ("write", name, target, key) + elif name == "Bash": + command = payload.get("command") + if isinstance(command, str): + for path in paths_in_command(command): + yield ("bash", name, path, f"{key}|{path}") + + +def _codex_entries(line: dict) -> Iterator[tuple[str, str, str, str]]: + """(op, tool, path, dedupe_key) for one Codex JSONL line. + + Codex records shell work as function calls whose arguments are usually a + JSON string. An unparseable argument blob yields nothing rather than a + guess -- a wrong path in a reverse index sends the operator to the wrong + file. + """ + payload = line.get("payload") + if not isinstance(payload, dict): + return + name = payload.get("name") + if name not in CODEX_COMMAND_TOOLS: + return + raw = payload.get("arguments") + args: dict = {} + if isinstance(raw, str): + try: + parsed = json.loads(raw) + except ValueError: + return + if isinstance(parsed, dict): + args = parsed + elif isinstance(raw, dict): + args = raw + command_field = args.get("cmd", args.get("command")) + if isinstance(command_field, list): + command = " ".join(str(part) for part in command_field) + elif command_field is None: + command = "" + else: + command = str(command_field) + + seen: list[str] = paths_in_command(command) if command else [] + workdir = args.get("workdir") + if isinstance(workdir, str) and workdir and workdir not in seen: + seen.append(workdir) + call_id = payload.get("call_id") or payload.get("id") + key_base = call_id if isinstance(call_id, str) and call_id else command + for path in seen: + yield ("bash", str(name), path, f"{key_base}|{path}") + + +def scan_transcript(source: Path, tool: str) -> tuple[list[tuple[str, str, str, Optional[str]]], int]: + """(op, tool, path, timestamp) rows for one transcript, plus a line count.""" + rows: list[tuple[str, str, str, Optional[str]]] = [] + seen_blocks: set = set() + lines_read = 0 + extract = _claude_entries if tool == "Claude" else _codex_entries + try: + with source.open("r", encoding="utf-8", errors="replace") as handle: + for raw in handle: + if lines_read >= MAX_LINES_PER_SESSION: + break + raw = raw.strip() + if not raw: + continue + lines_read += 1 + try: + line = json.loads(raw) + except ValueError: + continue + if not isinstance(line, dict): + continue + ts = line.get("timestamp") + if not isinstance(ts, str): + ts = None + for op, tool_name, path, key in extract(line): + if key in seen_blocks: + continue + seen_blocks.add(key) + rows.append((op, tool_name, path, ts)) + except OSError: + return rows, lines_read + return rows, lines_read + + +# --------------------------------------------------------------------------- +# Inverted index +# --------------------------------------------------------------------------- + +def build_index(home: Path, *, max_sessions: int = DEFAULT_MAX_SESSIONS) -> dict: + claude_records, claude_status = collect_claude(home) + codex_records, codex_status = collect_codex(home) + + sessions = [r for r in claude_records + codex_records if r.get("kind") == "session"] + sessions.sort(key=lambda r: r.get("last_active") or 0, reverse=True) + skipped = max(0, len(sessions) - max_sessions) + sessions = sessions[:max_sessions] + + by_path: dict[str, dict] = {} + scanned = 0 + for record in sessions: + source = Path(record["source"]) + rows, _ = scan_transcript(source, record["tool"]) + if not rows: + scanned += 1 + continue + session_id = source.stem + for op, tool_name, raw_path, ts in rows: + path = canonical_path(raw_path, home) + if not path: + continue + entry = by_path.get(path) + if entry is None: + entry = { + "path": path, + "rule_surface": is_rule_surface(path), + "reads": 0, "writes": 0, "shell": 0, + "tools": set(), "sessions": set(), + "last_ts": None, + } + by_path[path] = entry + entry["reads" if op == "read" else "writes" if op == "write" else "shell"] += 1 + entry["tools"].add(tool_name) + entry["sessions"].add(session_id) + if ts and (entry["last_ts"] is None or ts > entry["last_ts"]): + entry["last_ts"] = ts + scanned += 1 + + paths = [] + for entry in by_path.values(): + paths.append({ + "path": mask_text(entry["path"], home), + "rule_surface": entry["rule_surface"], + "reads": entry["reads"], + "writes": entry["writes"], + "shell": entry["shell"], + "tools": sorted(entry["tools"]), + "session_count": len(entry["sessions"]), + "session_ids": sorted(entry["sessions"])[:20], + "last_ts": entry["last_ts"], + }) + # Rule surfaces first, then breadth of exposure, then total touches: the + # order the contamination question is actually asked in. + paths.sort(key=lambda p: (not p["rule_surface"], -p["session_count"], + -(p["reads"] + p["writes"] + p["shell"]), p["path"])) + + return { + "generated_at": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime()), + "stores": [claude_status, codex_status], + "sessions_scanned": scanned, + "sessions_skipped_by_cap": skipped, + "path_count": len(paths), + "rule_surface_count": sum(1 for p in paths if p["rule_surface"]), + "paths": paths, + "evidence": "preview", + "requires_revalidation": True, + } + + +def filter_paths(index: dict, *, query: Optional[str], rule_only: bool) -> list[dict]: + paths = index["paths"] + if rule_only: + paths = [p for p in paths if p["rule_surface"]] + if query: + needle = query.lower() + paths = [p for p in paths if needle in p["path"].lower()] + return paths + + +def render_report(index: dict, paths: list[dict], limit: int) -> str: + lines = ["파일 접근 역색인 (읽기 전용)", ""] + lines.append(f"세션 {index['sessions_scanned']}개 · 경로 {index['path_count']}개 " + f"· 규칙 표면 {index['rule_surface_count']}개") + if index["sessions_skipped_by_cap"]: + lines.append(f"상한으로 건너뛴 세션 {index['sessions_skipped_by_cap']}개 " + "(--max-sessions 로 조정)") + lines.append("") + + if not paths: + lines.append("조건에 맞는 경로가 없습니다.") + return "\n".join(lines) + + for entry in paths[:limit]: + mark = "규칙" if entry["rule_surface"] else " " + lines.append(f"[{mark}] {entry['path']}") + lines.append(f" 읽기 {entry['reads']} · 쓰기 {entry['writes']} " + f"· 셸 {entry['shell']} · 세션 {entry['session_count']}개" + + (f" · 최근 {entry['last_ts']}" if entry["last_ts"] else "")) + if len(paths) > limit: + lines.append(f"... 그 외 {len(paths) - limit}개 생략") + + lines.append("") + lines.append("셸 경로는 명령문에서 추출한 추정치라 과잉·누락이 모두 발생합니다. " + "이 색인에 없다는 것은 어떤 에이전트도 건드리지 않았다는 뜻이 아니라, " + "색인된 세션 중에는 없다는 뜻입니다.") + return "\n".join(lines) + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser( + description="Reverse index: which local agent sessions touched which paths.") + parser.add_argument("-q", "--query", default=None, + help="substring filter over paths") + parser.add_argument("--all", action="store_true", + help="include paths that are not agent rule surfaces") + parser.add_argument("--max-sessions", type=int, default=DEFAULT_MAX_SESSIONS, + help="newest-first cap on sessions parsed in one run") + parser.add_argument("--json", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--limit", type=int, default=30, help=argparse.SUPPRESS) + parser.add_argument("--home", type=Path, default=Path.home(), help=argparse.SUPPRESS) + args = parser.parse_args(argv) + + index = build_index(args.home, max_sessions=args.max_sessions) + paths = filter_paths(index, query=args.query, rule_only=not args.all) + + if args.json: + print(json.dumps({**index, "paths": paths, + "filters": {"query": args.query, "rule_only": not args.all}}, + ensure_ascii=False, indent=2)) + else: + print(render_report(index, paths, args.limit)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/mcp_server.py b/scripts/mcp_server.py index 196de6a..d0d48a2 100644 --- a/scripts/mcp_server.py +++ b/scripts/mcp_server.py @@ -82,12 +82,15 @@ FRICTION = SCRIPT_DIR / "friction.py" HFSCAN = SCRIPT_DIR / "hfscan.py" MCPAUDIT = SCRIPT_DIR / "mcpaudit.py" +FILEACCESS = SCRIPT_DIR / "fileaccess.py" SCREE_TIMEOUT = 300 FRICTION_TIMEOUT = 300 # hfscan walks whole project trees; mcpaudit reads four small JSON files. HFSCAN_TIMEOUT = 600 MCPAUDIT_TIMEOUT = 60 +# fileaccess streams whole transcripts rather than their leading lines. +FILEACCESS_TIMEOUT = 600 # scree's full report is large (hundreds of lineage paths on a working machine). # Sections are selectable and lists are truncated, but never silently: every @@ -352,6 +355,37 @@ def tool_mcp_hygiene(args: dict) -> dict: } +def tool_file_access(args: dict) -> dict: + limit = _int_arg(args, "limit", default=30, minimum=1, maximum=500) + max_sessions = _int_arg(args, "max_sessions", default=400, minimum=1, maximum=4000) + include_all = bool(args.get("include_all", False)) + query = args.get("query") + if query is not None and not isinstance(query, str): + raise ToolFailure("query must be a string") + + arguments = ["--json", "--max-sessions", str(max_sessions)] + if include_all: + arguments.append("--all") + if query: + arguments += ["--query", query] + report = _run_json(FILEACCESS, arguments, FILEACCESS_TIMEOUT) + + paths = report.get("paths") or [] + items, note = _truncate(paths, limit) + return { + "evidence": report.get("evidence"), + "requires_revalidation": report.get("requires_revalidation"), + "stores": report.get("stores"), + "sessions_scanned": report.get("sessions_scanned"), + "sessions_skipped_by_cap": report.get("sessions_skipped_by_cap"), + "path_count": report.get("path_count"), + "rule_surface_count": report.get("rule_surface_count"), + "filters": {"query": query, "rule_surfaces_only": not include_all}, + "paths": items, + **note, + } + + def tool_system_scan_summary(args: dict) -> dict: limit = _int_arg(args, "limit", default=10, minimum=1, maximum=100) path, checked = _locate_scan_result() @@ -586,6 +620,42 @@ def _enum_arg(args: dict, name: str, allowed: tuple, default): "annotations": {"title": "MCP config hygiene — servers that cannot start", **READ_ONLY}, "handler": tool_mcp_hygiene, }, + { + "name": "file_access", + "title": "File access — which sessions touched which paths", + "description": ( + "Reverse index over local Claude Code and Codex transcripts: for each path, " + "how many reads, writes, and shell references it received, from how many " + "sessions, and when last. Agent rule and config surfaces -- CLAUDE.md, " + "AGENTS.md, settings.json, anything under ~/.claude or ~/.codex -- are " + "returned first and by default, because a silently edited rule file is the " + "case this view exists for; pass include_all to see ordinary files too. Ask " + "when something changed and no one remembers doing it, or to find every " + "session that touched a file before editing it again. Only paths and tool " + "names are retained: the shell command a path came from is never emitted. " + "Shell paths are recovered heuristically and both over- and under-catch, and " + "absence from this index means no *indexed* session touched the file, not " + "that no agent did. Read-only."), + "inputSchema": { + "type": "object", + "properties": { + "query": {"type": "string", + "description": "Substring filter over paths, case-insensitive."}, + "include_all": {"type": "boolean", "default": False, + "description": ("Include paths that are not agent rule " + "surfaces. Off by default.")}, + "max_sessions": {"type": "integer", "minimum": 1, "maximum": 4000, + "default": 400, + "description": ("Newest-first cap on transcripts parsed. " + "What the cap skipped is always reported.")}, + "limit": {"type": "integer", "minimum": 1, "maximum": 500, "default": 30, + "description": "Max paths returned. Truncation is always reported."}, + }, + "additionalProperties": False, + }, + "annotations": {"title": "File access — which sessions touched which paths", **READ_ONLY}, + "handler": tool_file_access, + }, { "name": "system_scan_summary", "title": "System scan summary — storage & security", @@ -620,7 +690,7 @@ def _enum_arg(args: dict, name: str, allowed: tuple, default): # point: "we simply never wrote a destructive tool" is an intention, and this # turns it into a mechanism. EXPOSED_TOOL_NAMES = frozenset({"scree_report", "friction_scan", "hf_orphans", - "mcp_hygiene", "system_scan_summary"}) + "mcp_hygiene", "file_access", "system_scan_summary"}) def contract_allows(tool: dict) -> bool: diff --git a/scripts/release_smoke.py b/scripts/release_smoke.py index 0ea62db..aee9e64 100644 --- a/scripts/release_smoke.py +++ b/scripts/release_smoke.py @@ -357,6 +357,7 @@ def verify_tag_with_signer( "scripts/friction.py", "scripts/hfscan.py", "scripts/mcpaudit.py", + "scripts/fileaccess.py", "scripts/mcp_server.py", "scripts/idle_cpu.sh", "scripts/network_watch.sh", diff --git a/tests/test_fileaccess.py b/tests/test_fileaccess.py new file mode 100644 index 0000000..6431f27 --- /dev/null +++ b/tests/test_fileaccess.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""fileaccess 계약 테스트. + +canary의 `get_file_access`를 옮기면서 지켜야 할 두 가지를 고정한다: +명령문 본문은 절대 나가지 않는다는 것(원본은 200자 발췌를 붙였다), 그리고 +같은 파일이 표기 차이로 여러 행이 되지 않는다는 것(역색인의 존재 이유). +""" +import json +from pathlib import Path + +import fileaccess + + +def _claude_session(home: Path, session_id: str, workspace: str, lines: list[dict]) -> Path: + project = home / ".claude" / "projects" / workspace.replace("/", "-") + project.mkdir(parents=True, exist_ok=True) + path = project / f"{session_id}.jsonl" + path.write_text("\n".join(json.dumps(line) for line in lines), encoding="utf-8") + return path + + +def _tool_use(tool: str, payload: dict, *, block_id: str, ts: str = "2026-08-01T00:00:00Z", + message_id: str = "msg1") -> dict: + return { + "timestamp": ts, + "cwd": "/tmp/ws", + "message": {"id": message_id, "role": "assistant", + "content": [{"type": "tool_use", "id": block_id, + "name": tool, "input": payload}]}, + } + + +# --------------------------------------------------------------------------- +# 규칙 표면 분류 (canary types.ts에서 그대로 옮김) +# --------------------------------------------------------------------------- + +def test_rule_basenames_and_directory_markers_are_flagged(): + for path in ("/a/CLAUDE.md", "/a/AGENTS.md", "/a/settings.json", + "/a/config.toml", "/home/x/.claude/anything", + "/home/x/.codex/sessions/s.jsonl", + "/repo/.github/instructions/x.md"): + assert fileaccess.is_rule_surface(path), path + + +def test_ordinary_source_files_are_not_flagged(): + for path in ("/a/main.py", "/a/README.md", "/a/claude.md.bak"): + assert not fileaccess.is_rule_surface(path), path + + +# --------------------------------------------------------------------------- +# 명령문에서 경로 추출 +# --------------------------------------------------------------------------- + +def test_urls_are_not_mistaken_for_paths(): + assert fileaccess.paths_in_command("curl https://example.com/a/b") == [] + + +def test_trailing_punctuation_is_trimmed_and_results_are_capped(): + assert fileaccess.paths_in_command("cat /etc/hosts, /tmp/a.txt.") == [ + "/etc/hosts", "/tmp/a.txt"] + many = " ".join(f"/tmp/f{i}" for i in range(20)) + assert len(fileaccess.paths_in_command(many)) == fileaccess.COMMAND_PATH_CAP + + +# --------------------------------------------------------------------------- +# 경로 정규화 — 역색인이 한 파일을 한 줄로 보여주기 위한 조건 +# --------------------------------------------------------------------------- + +def test_one_file_is_one_row_whatever_the_transcript_spelled(tmp_path): + """도구 입력은 절대경로로, 셸 명령은 틸데로 같은 파일을 가리킨다.""" + settings = tmp_path / ".claude" / "settings.json" + _claude_session(tmp_path, "s1", "/tmp/ws", [ + _tool_use("Read", {"file_path": str(settings)}, block_id="b1"), + _tool_use("Bash", {"command": "cat ~/.claude/settings.json"}, block_id="b2"), + _tool_use("Bash", {"command": "ls ~/.claude/"}, block_id="b3"), + _tool_use("Bash", {"command": "ls ~/.claude"}, block_id="b4"), + ]) + index = fileaccess.build_index(tmp_path) + rows = {p["path"]: p for p in index["paths"]} + + assert rows["~/.claude/settings.json"]["reads"] == 1 + assert rows["~/.claude/settings.json"]["shell"] == 1 + assert rows["~/.claude"]["shell"] == 2 # 후행 슬래시 유무가 갈라지지 않는다 + + +# --------------------------------------------------------------------------- +# 집계 +# --------------------------------------------------------------------------- + +def test_reads_writes_and_shell_are_counted_separately(tmp_path): + _claude_session(tmp_path, "s1", "/tmp/ws", [ + _tool_use("Read", {"file_path": "/tmp/ws/a.py"}, block_id="b1"), + _tool_use("Edit", {"file_path": "/tmp/ws/a.py"}, block_id="b2"), + _tool_use("Bash", {"command": "wc -l /tmp/ws/a.py"}, block_id="b3"), + ]) + row = fileaccess.build_index(tmp_path)["paths"][0] + assert (row["reads"], row["writes"], row["shell"]) == (1, 1, 1) + assert row["session_count"] == 1 + assert sorted(row["tools"]) == ["Bash", "Edit", "Read"] + + +def test_a_streamed_duplicate_block_is_counted_once_but_a_real_repeat_is_not(tmp_path): + """Claude는 한 메시지를 여러 줄로 흘려 쓰므로 같은 블록이 두 번 읽힌다. + 블록 id로 잡아야지, 경로로 잡으면 진짜 두 번째 읽기까지 사라진다.""" + duplicated = _tool_use("Read", {"file_path": "/tmp/ws/a.py"}, block_id="same") + genuine_repeat = _tool_use("Read", {"file_path": "/tmp/ws/a.py"}, block_id="other") + _claude_session(tmp_path, "s1", "/tmp/ws", [duplicated, duplicated, genuine_repeat]) + + assert fileaccess.build_index(tmp_path)["paths"][0]["reads"] == 2 + + +def test_sessions_touching_the_same_path_are_joined(tmp_path): + for session in ("s1", "s2"): + _claude_session(tmp_path, session, "/tmp/ws", [ + _tool_use("Read", {"file_path": "/tmp/ws/CLAUDE.md"}, block_id=f"{session}-b")]) + row = fileaccess.build_index(tmp_path)["paths"][0] + assert row["session_count"] == 2 + assert sorted(row["session_ids"]) == ["s1", "s2"] + + +def test_rule_surfaces_sort_ahead_of_busier_ordinary_files(tmp_path): + lines = [_tool_use("Read", {"file_path": "/tmp/ws/main.py"}, block_id=f"b{i}") + for i in range(10)] + lines.append(_tool_use("Read", {"file_path": "/tmp/ws/CLAUDE.md"}, block_id="rule")) + _claude_session(tmp_path, "s1", "/tmp/ws", lines) + + assert fileaccess.build_index(tmp_path)["paths"][0]["path"].endswith("CLAUDE.md") + + +def test_the_newest_timestamp_wins(tmp_path): + _claude_session(tmp_path, "s1", "/tmp/ws", [ + _tool_use("Read", {"file_path": "/tmp/ws/a.py"}, block_id="b1", + ts="2026-01-01T00:00:00Z"), + _tool_use("Read", {"file_path": "/tmp/ws/a.py"}, block_id="b2", + ts="2026-06-01T00:00:00Z"), + ]) + assert fileaccess.build_index(tmp_path)["paths"][0]["last_ts"] == "2026-06-01T00:00:00Z" + + +# --------------------------------------------------------------------------- +# 콘텐츠 계약 — 원본과 갈라지는 지점 +# --------------------------------------------------------------------------- + +def test_the_shell_command_itself_is_never_emitted(tmp_path): + """canary는 명령문 200자 발췌를 행마다 붙였다. 여기서는 경로만 남는다.""" + _claude_session(tmp_path, "s1", "/tmp/ws", [ + _tool_use("Bash", {"command": "grep -r 'hunter2' /tmp/ws/secrets.txt"}, + block_id="b1")]) + blob = json.dumps(fileaccess.build_index(tmp_path), ensure_ascii=False) + + assert "/tmp/ws/secrets.txt" in blob + assert "hunter2" not in blob + assert "grep" not in blob + + +def test_assistant_text_and_tool_results_are_never_emitted(tmp_path): + _claude_session(tmp_path, "s1", "/tmp/ws", [ + {"timestamp": "2026-08-01T00:00:00Z", "cwd": "/tmp/ws", + "message": {"role": "assistant", + "content": [{"type": "text", "text": "SENSITIVE-PROSE"}]}}, + {"timestamp": "2026-08-01T00:00:01Z", "cwd": "/tmp/ws", + "message": {"role": "user", + "content": [{"type": "tool_result", "content": "TOOL-OUTPUT"}]}}, + _tool_use("Read", {"file_path": "/tmp/ws/a.py"}, block_id="b1"), + ]) + blob = json.dumps(fileaccess.build_index(tmp_path), ensure_ascii=False) + assert "SENSITIVE-PROSE" not in blob + assert "TOOL-OUTPUT" not in blob + + +def test_non_path_tool_inputs_are_not_retained(tmp_path): + _claude_session(tmp_path, "s1", "/tmp/ws", [ + _tool_use("Grep", {"path": "/tmp/ws", "pattern": "MY-SECRET-REGEX"}, + block_id="b1")]) + assert "MY-SECRET-REGEX" not in json.dumps(fileaccess.build_index(tmp_path)) + + +def test_home_paths_are_masked(tmp_path): + _claude_session(tmp_path, "s1", "/tmp/ws", [ + _tool_use("Read", {"file_path": str(tmp_path / "notes.md")}, block_id="b1")]) + index = fileaccess.build_index(tmp_path) + assert index["paths"][0]["path"] == "~/notes.md" + assert str(tmp_path) not in json.dumps(index) + + +def test_nested_subagent_transcripts_are_never_opened(tmp_path): + """scree의 수집기 규약과 동일하다.""" + project = tmp_path / ".claude" / "projects" / "-tmp-ws" + nested = project / "parent-session" / "tool-results" + nested.mkdir(parents=True) + (nested / "sub.jsonl").write_text(json.dumps( + _tool_use("Read", {"file_path": "/tmp/ws/NESTED-ONLY.py"}, block_id="n1")), + encoding="utf-8") + _claude_session(tmp_path, "s1", "/tmp/ws", [ + _tool_use("Read", {"file_path": "/tmp/ws/a.py"}, block_id="b1")]) + + assert "NESTED-ONLY" not in json.dumps(fileaccess.build_index(tmp_path)) + + +def test_the_module_never_writes_and_never_deletes(): + source = Path(fileaccess.__file__).read_text(encoding="utf-8") + for forbidden in ("shutil", "os.remove", "os.unlink", "os.rmdir", + "write_text", "write_bytes", "subprocess", "os.system", + "shell=True"): + assert forbidden not in source, f"fileaccess must not use {forbidden}" + + +# --------------------------------------------------------------------------- +# 상한 · 필터 · 증거 등급 +# --------------------------------------------------------------------------- + +def test_the_session_cap_is_reported_not_silent(tmp_path): + for i in range(5): + _claude_session(tmp_path, f"s{i}", "/tmp/ws", [ + _tool_use("Read", {"file_path": f"/tmp/ws/f{i}.py"}, block_id=f"b{i}")]) + index = fileaccess.build_index(tmp_path, max_sessions=2) + assert index["sessions_scanned"] == 2 + assert index["sessions_skipped_by_cap"] == 3 + + +def test_rule_only_is_the_default_view(tmp_path): + _claude_session(tmp_path, "s1", "/tmp/ws", [ + _tool_use("Read", {"file_path": "/tmp/ws/CLAUDE.md"}, block_id="b1"), + _tool_use("Read", {"file_path": "/tmp/ws/main.py"}, block_id="b2"), + ]) + index = fileaccess.build_index(tmp_path) + assert len(fileaccess.filter_paths(index, query=None, rule_only=True)) == 1 + assert len(fileaccess.filter_paths(index, query=None, rule_only=False)) == 2 + assert len(fileaccess.filter_paths(index, query="main", rule_only=False)) == 1 + + +def test_every_index_is_labelled_preview_evidence(tmp_path): + index = fileaccess.build_index(tmp_path) + assert index["evidence"] == "preview" + assert index["requires_revalidation"] is True + + +def test_the_renderer_states_that_absence_is_not_proof(tmp_path): + _claude_session(tmp_path, "s1", "/tmp/ws", [ + _tool_use("Read", {"file_path": "/tmp/ws/CLAUDE.md"}, block_id="b1")]) + index = fileaccess.build_index(tmp_path) + text = fileaccess.render_report(index, index["paths"], 20) + assert "건드리지 않았다는 뜻이 아니라" in text diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 5255647..40036bd 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -24,9 +24,9 @@ def _payload(result: dict) -> dict: # --------------------------------------------------------------------------- def test_only_the_read_only_judgment_tools_are_exposed(): - assert sorted(mcp_server.HANDLERS) == ["friction_scan", "hf_orphans", - "mcp_hygiene", "scree_report", - "system_scan_summary"] + assert sorted(mcp_server.HANDLERS) == ["file_access", "friction_scan", + "hf_orphans", "mcp_hygiene", + "scree_report", "system_scan_summary"] def test_every_tool_is_annotated_read_only_and_non_destructive(): @@ -76,7 +76,8 @@ def fake_run(argv, **kwargs): assert spawned, "expected the judgment scripts to be invoked" for argv in spawned: script = Path(argv[3]).name - assert script in ("scree.py", "friction.py", "hfscan.py", "mcpaudit.py"), argv + assert script in ("scree.py", "friction.py", "hfscan.py", "mcpaudit.py", + "fileaccess.py"), argv joined = " ".join(argv) for forbidden in ("cleanup", "scanner", "storage_watch", "schedule", "preserve", "--raw"): @@ -122,6 +123,7 @@ def test_the_judgment_scripts_are_the_declared_targets(): assert mcp_server.FRICTION.name == "friction.py" assert mcp_server.HFSCAN.name == "hfscan.py" assert mcp_server.MCPAUDIT.name == "mcpaudit.py" + assert mcp_server.FILEACCESS.name == "fileaccess.py" def test_scan_summary_states_that_it_cannot_start_a_scan(tmp_path, monkeypatch): @@ -298,7 +300,7 @@ def test_tools_list_declares_closed_input_schemas(): tools = mcp_server.handle_request("tools/list", {})["tools"] assert [t["name"] for t in tools] == ["scree_report", "friction_scan", "hf_orphans", "mcp_hygiene", - "system_scan_summary"] + "file_access", "system_scan_summary"] for tool in tools: assert tool["inputSchema"]["additionalProperties"] is False assert tool["description"] and tool["title"] @@ -348,7 +350,7 @@ def test_cli_tools_dump_is_the_registered_surface(capsys): dumped = json.loads(capsys.readouterr().out) assert [t["name"] for t in dumped["exposed"]] == ["scree_report", "friction_scan", "hf_orphans", "mcp_hygiene", - "system_scan_summary"] + "file_access", "system_scan_summary"] assert dumped["rejected"] == [] @@ -414,3 +416,37 @@ def test_mcp_hygiene_filters_by_status_and_never_forwards_env(monkeypatch): assert "env_key_count" in everything for leaked in ("ANTHROPIC_API_KEY", "sk-ant", "env_values"): assert leaked not in everything + + +def test_file_access_defaults_to_rule_surfaces_and_never_forwards_a_command(monkeypatch): + """canary는 행마다 명령문 200자 발췌를 실었다. 이 표면은 경로만 넘긴다.""" + captured = {} + + def fake_run(script, arguments, timeout): + captured["argv"] = arguments + return { + "evidence": "preview", "requires_revalidation": True, + "stores": [], "sessions_scanned": 3, "sessions_skipped_by_cap": 0, + "path_count": 1, "rule_surface_count": 1, + "paths": [{"path": "~/.claude/settings.json", "rule_surface": True, + "reads": 1, "writes": 2, "shell": 0, "tools": ["Edit"], + "session_count": 2, "session_ids": ["a", "b"], + "last_ts": "2026-08-01T00:00:00Z"}], + } + + monkeypatch.setattr(mcp_server, "_run_json", fake_run) + + payload = _payload(_call("file_access", {})) + assert payload["filters"]["rule_surfaces_only"] is True + assert "--all" not in captured["argv"] + assert payload["paths"][0]["path"] == "~/.claude/settings.json" + for key in ("detail", "command", "cmd"): + assert key not in payload["paths"][0] + + _call("file_access", {"include_all": True, "query": "settings"}) + assert "--all" in captured["argv"] + assert "--query" in captured["argv"] and "settings" in captured["argv"] + + +def test_file_access_rejects_a_non_string_query(): + assert _call("file_access", {"query": 5}).get("isError") diff --git a/tests/test_release_hardening.py b/tests/test_release_hardening.py index 9fad43f..5564eb3 100644 --- a/tests/test_release_hardening.py +++ b/tests/test_release_hardening.py @@ -1325,6 +1325,83 @@ def test_release_ships_frictions_and_the_mcp_surfaces_dependencies(project_root) assert script in module.MACOS_FILES +# MothballCore's destructive half. These are linked into Modore's binary by the +# vendor dependency but must stay unreachable from Modore's own code until the +# approval discipline below is satisfied. +MOTHBALL_DESTRUCTIVE_SYMBOLS = ( + "ArchiveOrchestrator", + "Restorer", + "ArchiveRun", + "trashItem", +) + + +def test_modore_cannot_reach_mothballs_destructive_api_without_the_approval_gate(project_root): + """Absorbing Mothball absorbed two different deletion disciplines, and only + one of them is Modore's. + + Modore destroys nothing without a preview that issues a single-use 64-byte + approval token, a 15-minute owner-only manifest naming canonical paths and + measured sizes, a remeasure at the destructive boundary, and a receipt -- + `create_approval_manifest` / `consume_approval_manifest` in cleanup.sh, the + token check in CleanupModels.swift, the staging in RuntimeWorkspace. That + chain is what README's "destruction is gated on an on-screen human + approval" actually refers to. + + MothballCore's `ArchiveOrchestrator.archive()` is careful in its own right + -- it refuses `/` and `$HOME`, verifies the archive before touching the + original, and moves to Trash rather than unlinking -- but it is an + IN-PROCESS Swift call that takes no token and consumes no manifest. It is + already compiled into Modore's binary through the vendor dependency, so + nothing but this test stands between a future `try orchestrator.archive(…)` + in a view action and a second deletion path that the approval chain never + sees. + + Wiring it is allowed. Wiring it *around* the token is not: any file that + reaches the destructive API must also carry the approval symbol, so the + reviewer of that diff is looking at both halves at once. Today no file + reaches it at all, and this test says so out loud rather than leaving it to + be rediscovered. + """ + sources = sorted((project_root / "macos" / "Modore" / "Sources").rglob("*.swift")) + assert sources, "expected Modore Swift sources to exist" + + unguarded: list[str] = [] + for path in sources: + text = path.read_text(encoding="utf-8") + # Comments explain the boundary; only code may not cross it. + code = "\n".join(line for line in text.splitlines() + if not line.lstrip().startswith(("//", "///", "*"))) + touched = [sym for sym in MOTHBALL_DESTRUCTIVE_SYMBOLS if sym in code] + if not touched: + continue + if "approvalToken" not in code: + rel = path.relative_to(project_root) + unguarded.append(f"{rel}: {', '.join(touched)}") + + assert not unguarded, ( + "Mothball's destructive API is reachable from Modore without the " + "approval-token discipline. Route it through the preview/approve/" + "execute chain -- or, if this is deliberate, say why here:\n " + + "\n ".join(unguarded)) + + +def test_the_shipped_mothball_surface_is_scan_and_classify_only(project_root): + """The other half of the same boundary, stated positively: what Modore + actually uses from MothballCore today is its read-only git inspection. + + Pinned because the import is what makes the destructive half available -- + `import MothballCore` brings the whole module, not the two types + MothballService names -- so the harmless-looking line is the one worth + keeping under review.""" + service = (project_root / "macos" / "Modore" / "Sources" / "Modore" + / "Services" / "MothballService.swift").read_text(encoding="utf-8") + assert "RepoScanner()" in service + assert "SafetyClassifier" in service + for destructive in MOTHBALL_DESTRUCTIVE_SYMBOLS: + assert destructive not in service + + @pytest.mark.skipif(sys.platform != "darwin", reason="swift build is macOS-only") def test_release_extracted_swift_package_actually_builds(project_root, tmp_path): """Ground truth for the Package.swift -> vendor/mothball local dependency: diff --git a/tests/test_service_contracts.py b/tests/test_service_contracts.py index 55975d5..d17c21d 100644 --- a/tests/test_service_contracts.py +++ b/tests/test_service_contracts.py @@ -641,14 +641,16 @@ def test_bundled_app_runtime_includes_every_macos_script(project_root): "scripts/scanner_helper.py", # CLI-and-MCP-only surfaces: the app has no view that runs any of them, # so shipping them inside the signed bundle would add unreachable code - # to the sealed runtime. friction.py, hfscan.py, and mcpaudit.py belong - # here only while they have no Swift caller -- give one a card and it - # must move into RUNTIME_FILES, the same way scree.py did. + # to the sealed runtime. friction.py, hfscan.py, mcpaudit.py, and + # fileaccess.py belong here only while they have no Swift caller -- + # give one a card and it must move into RUNTIME_FILES, the same way + # scree.py did. # mcp_server.py is a stdio server that a separate MCP client launches # from a checkout; the app never spawns it. "scripts/friction.py", "scripts/hfscan.py", "scripts/mcpaudit.py", + "scripts/fileaccess.py", "scripts/mcp_server.py", } non_shell_expected = { From 2bb501d3b2340548828635070229e6c46fd9dd6e Mon Sep 17 00:00:00 2001 From: Heznpc Date: Wed, 19 Aug 2026 20:44:23 +0900 Subject: [PATCH 3/3] Drop the UI's pointer to the standalone Mothball app The archive page's copy dated from the display-first stage of the absorption, when vendor/mothball had just been merged and Mothball was still a live standalone product -- so "run the Mothball app separately" was accurate advice at the time. Archiving that repository removed the thing being pointed at, and the copy did not follow. It now says what is actually true: there is no archive execution anywhere yet, and when one is wired it goes through the same preview-and-approval path as every other deletion in this app -- which the guard added alongside this now enforces rather than merely promises. Verified: swift build and 169 Swift tests pass, boundary guards pass. --- macos/Modore/Sources/Modore/Views/MothballView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Modore/Sources/Modore/Views/MothballView.swift b/macos/Modore/Sources/Modore/Views/MothballView.swift index b532811..8d6da31 100644 --- a/macos/Modore/Sources/Modore/Views/MothballView.swift +++ b/macos/Modore/Sources/Modore/Views/MothballView.swift @@ -50,7 +50,7 @@ struct MothballPage: View { } header: { NativeSectionHeader( title: "저장소 보관 후보", - subtitle: "MothballCore가 git 메타데이터만으로 판정한 미리보기입니다. 실제 압축·보관 실행은 아직 지원하지 않습니다.", + subtitle: "git 메타데이터만으로 판정한 미리보기입니다. 압축·보관 실행은 아직 없습니다.", value: model.archiveCandidates != nil ? "완료" : "" ) } @@ -104,7 +104,7 @@ private struct MothballCandidateSection: View { } header: { NativeSectionHeader( title: "후보 목록", - subtitle: "압축 후 원본 삭제는 이 화면에서 지원하지 않습니다. 필요하면 Mothball 앱을 별도로 사용하세요.", + subtitle: "압축 후 원본 삭제는 아직 어디에서도 실행되지 않습니다. 실행이 붙으면 다른 삭제 작업과 같이 미리보기·승인 절차를 거칩니다.", value: candidates.isEmpty ? "" : "\(candidates.count)개" ) }