diff --git a/CHANGELOG.md b/CHANGELOG.md index f21b145..4fc24f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ ### Fixed +- Prevent explicit `./` and `../` prose references from passing through an unrelated package's suffix match while retaining existing repository-root/AGENTS-scope interpretations and accepting explicit document-relative paths in other formats. Preserve significant hidden-directory dots and skip references with no in-repository interpretation before index lookup. +- Stop scans on directory traversal errors rather than silently using a partial path index. Scan/compare/feedback report operational exit `2` without success output; `doctor` exposes a structured `repository-paths` error. Raw traversal messages are not echoed, and excluded dependency/admin trees remain pruned. - Avoid `dead-path` false positives for narrowly typed repository/remote slugs and immediate closed `(if exists)` prose references, without exempting other local paths, actual Markdown links or dangerous commands. - Keep real package-relative source paths beneath `build`/`dist`/`target`/`.next` in the existence index while preserving instruction-discovery exclusions. Prune dependency/admin trees before indexing and do not follow directory symlinks or index external/broken link entries. English, Simplified and Traditional limitation notes stay aligned. diff --git a/benchmarks/prospective-review-v1/tool-probe/development/README.md b/benchmarks/prospective-review-v1/tool-probe/development/README.md index fc3837c..1569cf6 100644 --- a/benchmarks/prospective-review-v1/tool-probe/development/README.md +++ b/benchmarks/prospective-review-v1/tool-probe/development/README.md @@ -14,6 +14,11 @@ The tool and development script have recorded source fingerprints. Package version `0.23.0` alone does not identify this **unreleased** rule state; this is not a scan with the immutable published v0.23.0 wheel. +This retained run was completed on **2026-10-11** and identifies that day's +development source, not every subsequent `main` revision. Later path/operational +fixes do not retroactively update this report. Reproduction with newer code must +use a new output path and remain explicitly development-only. + | Scope | Files | Frozen findings | Development findings | |---|---:|---:|---:| | Native discovery | 27 | 8 `dead-path`, 1 `context-large` | 1 `context-large` | diff --git a/docs/doctor.md b/docs/doctor.md index cc8e255..3164fab 100644 --- a/docs/doctor.md +++ b/docs/doctor.md @@ -11,6 +11,7 @@ The command is read-only and offline. It checks: - whether `.agentconfigscore.json` exists and parses successfully; - whether the config uses the canonical AgentConfigScore JSON Schema; - whether supported coding-agent instruction files are discoverable; +- whether the repository path index can be traversed without a filesystem error; - whether the directory is inside a Git repository for `diff` workflows; - whether `agent-config-score diff` can automatically resolve a safe local baseline; - whether the standard GitHub Actions workflow exists and invokes AgentConfigScore correctly; @@ -20,6 +21,13 @@ When automatic baseline detection succeeds, doctor reports the selected ref, for Warnings are advisory and keep exit status `0`. Concrete configuration or standard-workflow errors return a non-zero exit status. +The `repository-paths` check reports an error if index traversal fails, for +example because a source directory cannot be read. Fix the access problem before +scanning; an incomplete index must not be treated as a clean result or evidence +that a file is missing. Excluded dependency/admin trees are not traversed by this +index check. It checks path metadata only, not instruction quality, tracked-file +membership, or an atomic snapshot; access can change after the check completes. + For scripts and CI: ```bash diff --git a/docs/limitations.md b/docs/limitations.md index 3ad0dc7..51b589b 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -85,6 +85,19 @@ Markdown validator. ## Plain prose path references +Backtick file references starting with `./` or `../` are checked against the +repository root and applicable instruction-directory bases. Nested AGENTS-family +files retain their in-repository ancestor interpretations; other formats also +accept an explicit document-relative reference. These paths cannot pass merely +because an unrelated package has a matching suffix. Significant leading dots, +such as `.github`, are preserved. If no interpretation stays inside the repository, +the reference is skipped rather than probed outside or substituted with an +indexed path. Actual Markdown links remain strictly document-relative. + +Unprefixed prose still uses conservative package-relative suffix matching. +Neither mode infers the working directory of a described command, follows `cd` +instructions, or proves which interpretation the author intended. + File-like prose references are heuristic, not a complete natural-language path resolver. An immediate, closed `(if exists)` or `(if it exists)` condition makes only that prose reference optional; Markdown link destinations remain checked. @@ -108,6 +121,13 @@ or intended by an ambiguous reference. Generated/untracked files not present in the checkout can still be reported missing; filesystem case behavior remains host-dependent. These exemptions do not disable command or credential checks. +Directory traversal errors while building the index stop the scan with an +operational error, not a partial clean report or inferred missing path. CLI scan, +compare and feedback return exit `2` without a success report; `doctor` exposes +an error check named `repository-paths` and returns `1`. Dependency/admin trees +excluded from the index remain pruned. This is a local filesystem check, not a +Git-tracked-only index or an atomic filesystem snapshot. + ## Red-team audit The project added the following adversarial cases after testing the v0.18.0 diff --git a/docs/simplified/limitations.md b/docs/simplified/limitations.md index 7c92068..d67dbd3 100644 --- a/docs/simplified/limitations.md +++ b/docs/simplified/limitations.md @@ -42,12 +42,18 @@ AgentConfigScore 的分数衡量已经检测到的、由规则定义的风险, ## 普通散文中的路径 +反引号中的 `./`、`../` 文件路径按仓库根目录及适用的指令目录核对;嵌套 AGENTS 文件保留仓库内祖先目录的解释,其他格式也接受明确的文档相对路径。这些路径不会因无关包有相同后缀而通过。`.github` 等名称中的开头圆点会保留;所有解释都逃出仓库时,不访问外部路径,也不拿索引中的其他文件代替。真正的 Markdown 链接仍严格相对于文档目录。 + +无前缀的散文路径仍使用保守的包相对后缀匹配。两种模式都不推断命令的工作目录、不执行或跟踪 `cd` 指令,也不证明哪一种解释符合作者意图。 + 只在紧邻路径且闭合的 `(if exists)`、`(if it exists)` 条件下,把对应散文路径视为可选;括号内 `-` 或 `:` 后可带简短解释。真正的 Markdown 链接仍检查目标。同一行的其他路径、远处的“可选”字样、不闭合的条件不会因此豁免。 `owner/tool.js` 这类两段名称,只在明确的同一行仓库角色语法中跳过:后接 `repository`/`remote` 及标点,或前接 `merge target is`、`fork of`、`re-pushed to a branch in`、`CI trusts branches inside`;也识别相同仓库名的 `fork of ... and not ... itself` 形式。仅提到 GitHub 或仓库,不会让本地路径免检。其他改写和语言仍可能误判。[完整英文边界](../limitations.md#plain-prose-path-references)。 包相对路径的后缀索引包含 `build`、`dist`、`target`、`.next` 中已经存在的路径,但不会因此扩大指令文件的发现范围。依赖、管理目录仍排除在后缀索引外;不遍历目录符号链接,也不索引外部或失效链接。存在不代表内容正确或路径意图明确;未生成的文件仍可能被报告缺失,大小写判断依赖宿主文件系统。这些豁免不会关闭危险命令或凭据检查。 +构建索引时的目录遍历错误会停止扫描,不把部分索引当作干净结果或文件缺失的证据。扫描、`compare`、`feedback` 返回 `2`,不输出成功报告;`doctor` 给出 `repository-paths` 错误项并返回 `1`。已排除的依赖、管理目录仍不遍历。这是本地文件系统检查,不是仅限 Git 跟踪文件的索引,也不是原子文件系统快照。 + ## 回归保护的具体范围 - 没有支持的指令文件会产生 `no-config`;空文件会产生 `empty-instructions`,不会显示 A 等级。 diff --git a/docs/simplified/user-guide.md b/docs/simplified/user-guide.md index d22ef61..d8c56af 100644 --- a/docs/simplified/user-guide.md +++ b/docs/simplified/user-guide.md @@ -107,6 +107,7 @@ agent-config-score feedback . --output .agent-config-score/case.md |---|---| | 找不到命令 | 确认装入当前 Python 环境,并检查 Scripts/bin 的 PATH。v0.23.0+ 可用 `python -m agent_config_score`。 | | 配置或工作流编码错误 | 保存为 UTF-8,不要用 UTF-16 文件替代。 | +| `Unable to read repository path index` | 运行 `doctor`,核对 `repository-paths` 错误并修复目录访问或 I/O 问题。部分索引不会生成成功报告。 | | 报告不可写 | 选择可写的文件路径,不是已有目录;检查父目录权限。 | | 无法检测基线 | 运行 `doctor`,然后指定本地已有的默认分支引用。 | | 禁止示例被报为危险指令 | 对照[支持的禁止语法(英文)](../instruction-context.md)检查具体上下文;工具不理解任意语言和改写。 | diff --git a/docs/traditional/limitations.md b/docs/traditional/limitations.md index 005b7c0..c59c99a 100644 --- a/docs/traditional/limitations.md +++ b/docs/traditional/limitations.md @@ -42,12 +42,18 @@ AgentConfigScore 的分數衡量已偵測到、由規則定義的風險,不衡 ## 一般敘述中的路徑 +反引號中的 `./`、`../` 檔案路徑依儲存庫根目錄及適用的指令目錄檢查;巢狀 AGENTS 檔保留儲存庫內祖先目錄的解釋,其他格式也接受明確的文件相對路徑。這些路徑不會因無關套件有相同後綴而通過。`.github` 等名稱的開頭圓點會保留;所有解釋都離開儲存庫時,不存取外部路徑,也不以索引中的其他檔案替代。實際的 Markdown 連結仍嚴格依文件目錄解析。 + +無前綴的敘述路徑仍使用保守的套件相對後綴匹配。兩種模式都不推論命令的工作目錄、不執行或追蹤 `cd` 指令,也不證明哪一種解釋符合作者意圖。 + 只在緊鄰路徑且閉合的 `(if exists)`、`(if it exists)` 條件下,將對應敘述路徑視為選用;括號內 `-` 或 `:` 後可附簡短說明。實際的 Markdown 連結仍檢查目標。同一行的其他路徑、遠處的「選用」字樣、未閉合條件不會因此豁免。 `owner/tool.js` 這類兩段名稱,只在明確的同一行儲存庫角色語法中略過:後接 `repository`/`remote` 及標點,或前接 `merge target is`、`fork of`、`re-pushed to a branch in`、`CI trusts branches inside`;也識別相同儲存庫名稱的 `fork of ... and not ... itself` 形式。僅提到 GitHub 或儲存庫,不會讓本機路徑免檢。其他改寫及語言仍可能誤判。[完整英文界線](../limitations.md#plain-prose-path-references)。 套件相對路徑的後綴索引包含 `build`、`dist`、`target`、`.next` 中已存在的路徑,但不會因此擴大指令檔的探索範圍。相依套件、管理目錄仍排除於後綴索引之外;不遍歷目錄符號連結,也不索引外部或失效連結。存在不代表內容正確或路徑意圖明確;未產生的檔案仍可能被回報缺失,大小寫判斷依賴宿主檔案系統。這些豁免不會關閉危險命令或認證資訊檢查。 +建立索引時的目錄遍歷錯誤會停止掃描,不將部分索引當成乾淨結果或檔案缺失的證據。掃描、`compare`、`feedback` 返回 `2`,不輸出成功報告;`doctor` 提供 `repository-paths` 錯誤項並返回 `1`。已排除的相依套件、管理目錄仍不遍歷。這是本機檔案系統檢查,不是僅限 Git 追蹤檔案的索引,也不是原子檔案系統快照。 + ## 回歸保護的具體範圍 - 沒有支援的指令檔會產生 `no-config`;空白檔案會產生 `empty-instructions`,不會顯示 A 等級。 diff --git a/docs/traditional/user-guide.md b/docs/traditional/user-guide.md index 173f5ae..1f429e7 100644 --- a/docs/traditional/user-guide.md +++ b/docs/traditional/user-guide.md @@ -107,6 +107,7 @@ agent-config-score feedback . --output .agent-config-score/case.md |---|---| | 找不到命令 | 確認安裝在目前 Python 環境,並檢查 Scripts/bin 的 PATH。v0.23.0+ 可用 `python -m agent_config_score`。 | | 設定或工作流程編碼錯誤 | 儲存為 UTF-8,不要以 UTF-16 檔案取代。 | +| `Unable to read repository path index` | 執行 `doctor`,檢查 `repository-paths` 錯誤並修正目錄存取或 I/O 問題。部分索引不會產生成功報告。 | | 報告無法寫入 | 選擇可寫入的檔案路徑,不是既有目錄;檢查父目錄權限。 | | 無法偵測基準 | 執行 `doctor`,再指定本機已存在的預設分支參照。 | | 禁止範例被判為危險指令 | 對照[支援的禁止語法(英文)](../instruction-context.md)檢查上下文;工具不理解任意語言和改寫。 | diff --git a/docs/translations.md b/docs/translations.md index 6f2a2ca..1a39ffd 100644 --- a/docs/translations.md +++ b/docs/translations.md @@ -37,6 +37,13 @@ path-reference changes built on source revision roles, path-index/discovery separation, symlink and host-case limits. This is an unreleased synchronized source update, not a change to the original v0.23.0 tag. +On 2026-10-14, both guides and limitation summaries were reviewed together with +the English explicit-relative-path and index-I/O changes built on source revision +`ef733582a47233c96cdc76b0017b9880f415e5da`. The existing root/AGENTS scope +interpretations remain, arbitrary suffix substitution is disabled for explicit +relative references, and index read failures stay operational/doctor errors. +Commands and the published v0.23.0 artifacts remain unchanged. + When an English source page changes, review both variants together and record the new source revision here. Preserve executable examples, rule IDs and JSON keys verbatim. Keep the score limitations and failure/exit semantics aligned; diff --git a/docs/user-guide.md b/docs/user-guide.md index 2aa84db..d2acc12 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -145,6 +145,7 @@ Nothing is uploaded. Review and sanitize the draft yourself before sharing. | Command is not found | Install into the active Python environment; confirm its Scripts/bin directory is on PATH. In v0.23.0+, use `python -m agent_config_score` as a PATH-independent alternative. | | Configuration / workflow encoding error | Save the file as UTF-8; do not copy a UTF-16 file into the integration. | | Cannot write HTML, SARIF or Markdown | Choose a writable file path, not an existing directory; inspect parent-directory permissions. | +| `Unable to read repository path index` | Run `doctor` and check its `repository-paths` error. Fix directory access or I/O failures; no success report is emitted from a partial index. | | `diff` cannot detect a baseline | Check `doctor`, then pass an available default-branch ref explicitly. | | A prohibited command was flagged | Compare its exact context with the [supported prohibition grammar](instruction-context.md); arbitrary languages and paraphrases are not understood. | | Historic GitHub run is red | Open the run for the current commit. Fixing a later commit does not erase an earlier failed run. | diff --git a/src/agent_config_score/doctor.py b/src/agent_config_score/doctor.py index 3485739..6a2581f 100644 --- a/src/agent_config_score/doctor.py +++ b/src/agent_config_score/doctor.py @@ -7,7 +7,7 @@ from .config import CONFIG_NAME, SCHEMA_URL, ConfigError, load_policy from .gitdiff import GitError, detect_base_ref, repository_root -from .scanner import discover +from .scanner import _repository_path_index, discover @dataclass(frozen=True) @@ -167,6 +167,18 @@ def diagnose(root: Path) -> DoctorReport: "No supported coding-agent instruction files were discovered.", )) + try: + _repository_path_index(root) + except OSError: + checks.append(DoctorCheck( + "repository-paths", "error", + "Could not build the repository path index; check filesystem access before scanning.", + )) + else: + checks.append(DoctorCheck( + "repository-paths", "pass", "Repository path index is readable.", + )) + try: git_root = repository_root(root) except GitError as exc: diff --git a/src/agent_config_score/scanner.py b/src/agent_config_score/scanner.py index 62e5a9c..70b7d15 100644 --- a/src/agent_config_score/scanner.py +++ b/src/agent_config_score/scanner.py @@ -793,7 +793,10 @@ def _candidate_exists( candidate: str, repository_paths: set[str] | None = None, ) -> bool | None: - # Repository-root relative paths remain the primary interpretation. For a + candidate = candidate.replace("\\", "/") + explicit_relative = candidate.startswith(("./", "../")) + # Prose can describe commands run from the repository root rather than + # from the instruction directory. Preserve that root interpretation. For a # nested AGENTS-family file, also accept a path relative to that file's # directory, which is the root of its instruction scope. bases = [root] @@ -805,6 +808,10 @@ def _candidate_exists( while current != root: bases.append(current) current = current.parent + elif explicit_relative and source.parent != root: + # Explicit relative notation may also be document-relative in other + # formats. Only these concrete bases count, not an arbitrary suffix. + bases.append(source.parent) checked = False for base in bases: @@ -814,11 +821,21 @@ def _candidate_exists( checked = True if target.exists(): return True - if repository_paths is not None: - normalized = candidate.lstrip("./") + if not checked: + return None + if not explicit_relative and repository_paths is not None: + # lstrip('./') removes characters, not a path prefix: it previously + # erased the significant dot in .github and dots in parent traversals. + normalized = PurePosixPath(candidate).as_posix() if any(path == normalized or path.endswith("/" + normalized) for path in repository_paths): return True - return False if checked else None + return False + + +def _path_index_error(exc: OSError) -> None: + # A partial index is not evidence that a referenced path is missing. Keep + # source-controlled path names and raw filesystem messages out of the CLI. + raise OSError(exc.errno, "Unable to read repository path index") from exc def _repository_path_index(root: Path) -> set[str]: @@ -826,7 +843,7 @@ def _repository_path_index(root: Path) -> set[str]: paths: set[str] = set() # Prune dependency/admin trees before descent, and never follow directory # symlinks. This indexes names only; it does not read generated source files. - for directory, dirs, files in os.walk(root, followlinks=False): + for directory, dirs, files in os.walk(root, followlinks=False, onerror=_path_index_error): descend = [] for name in dirs: if name in PATH_INDEX_SKIP_DIRS: diff --git a/tests/test_cli_failures.py b/tests/test_cli_failures.py index 83e7159..c6a15ed 100644 --- a/tests/test_cli_failures.py +++ b/tests/test_cli_failures.py @@ -1,6 +1,7 @@ import contextlib import io import json +import os from pathlib import Path import tempfile import unittest @@ -105,6 +106,51 @@ def test_unreadable_ignore_file_is_not_a_clean_scan(self): check = next(item for item in report.checks if item.name == "instructions") self.assertEqual(check.status, "error") + def test_unreadable_path_index_does_not_emit_a_success_report(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + source = root / "AGENTS.md" + source.write_text("Run tests.\n", encoding="utf-8") + blocked = root / "src" + blocked.mkdir() + original = os.scandir + + def inaccessible(path): + if Path(path) == blocked: + raise PermissionError(13, "sensitive-error-detail", str(blocked)) + return original(path) + + invocations = ([str(root), "--json", "--html", str(root / "report.html")], + ["compare", str(root), str(root), "--json"], + ["feedback", str(root), "--output", str(root / "feedback.md")]) + for args in invocations: + with self.subTest(args=args), patch("agent_config_score.scanner.discover", return_value=[source]), \ + patch("agent_config_score.scanner.os.scandir", side_effect=inaccessible): + code, stdout, stderr = self.invoke(args) + self.assertEqual(code, 2) + self.assertEqual(stdout, "") + self.assertIn("Unable to read repository path index", stderr) + self.assertNotIn("sensitive-error-detail", stderr) + self.assertNotIn("Traceback", stderr) + self.assertFalse((root / "report.html").exists()) + self.assertFalse((root / "feedback.md").exists()) + + def test_doctor_marks_unreadable_path_index_as_structured_error(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + source = root / "AGENTS.md" + source.write_text("Run tests.\n", encoding="utf-8") + with patch("agent_config_score.doctor._repository_path_index", + side_effect=PermissionError(13, "sensitive-error-detail")): + code, stdout, stderr = self.invoke(["doctor", str(root), "--json"]) + self.assertEqual(code, 1) + self.assertEqual(stderr, "") + report = json.loads(stdout) + self.assertFalse(report["ok"]) + check = next(item for item in report["checks"] if item["name"] == "repository-paths") + self.assertEqual(check["status"], "error") + self.assertNotIn("sensitive-error-detail", stdout) + def test_write_permission_failure_has_concise_diagnostic(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 5ef3b32..61c8da7 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -82,6 +82,7 @@ def test_ready_repository_has_no_errors(self): self.assertEqual(statuses["config"], "pass") self.assertEqual(statuses["schema"], "pass") self.assertEqual(statuses["instructions"], "pass") + self.assertEqual(statuses["repository-paths"], "pass") self.assertEqual(statuses["workflow"], "pass") self.assertEqual(statuses["git"], "warning") self.assertNotIn("baseline", statuses) diff --git a/tests/test_path_references.py b/tests/test_path_references.py index 9754251..b045001 100644 --- a/tests/test_path_references.py +++ b/tests/test_path_references.py @@ -4,7 +4,7 @@ import unittest from unittest.mock import patch -from agent_config_score.scanner import _repository_path_index, analyze, discover +from agent_config_score.scanner import _candidate_exists, _repository_path_index, analyze, discover FORMATS = ("AGENTS.md", "CLAUDE.md", "GEMINI.md", ".cursorrules", @@ -120,6 +120,88 @@ def test_dependency_tree_cannot_satisfy_package_relative_reference(self): self.assertEqual(self.dead_lines("Read `src/dependency.py`.\n", existing=("node_modules/client/src/dependency.py",)), [1]) + def test_explicit_relative_path_cannot_use_unrelated_suffix_match(self): + self.assertEqual(self.dead_lines("Read `./src/entry.py`.\n", + existing=("packages/client/src/entry.py",)), [1]) + self.assertEqual(self.dead_lines("Read `./src/entry.py`.\n", + existing=("src/entry.py",)), []) + + def test_backslash_relative_reference_uses_the_same_anchor(self): + filename = "packages/client/AGENTS.md" + self.assertEqual(self.dead_lines(r"Read `.\src\entry.py`." + "\n", filename=filename, + existing=("packages/other/src/entry.py",)), [1]) + self.assertEqual(self.dead_lines(r"Read `..\docs\guide.md`." + "\n", filename=filename, + existing=("packages/docs/guide.md",)), []) + + def test_explicit_relative_path_accepts_existing_root_or_instruction_scope(self): + for filename in ("packages/client/AGENTS.md", "packages/client/CLAUDE.md", + ".github/copilot-instructions.md", ".cursor/rules/review.mdc"): + with self.subTest(filename=filename): + self.assertEqual(self.dead_lines("Read `./src/entry.py`.\n", filename=filename, + existing=("src/entry.py",)), []) + local = str(Path(filename).parent / "src" / "entry.py") + self.assertEqual(self.dead_lines("Read `./src/entry.py`.\n", filename=filename, + existing=(local,)), []) + self.assertEqual(self.dead_lines("Read `./src/entry.py`.\n", filename=filename, + existing=("packages/other/src/entry.py",)), [1]) + + def test_parent_relative_path_cannot_choose_an_unrelated_suffix(self): + filename = "packages/client/instructions/AGENTS.md" + self.assertEqual(self.dead_lines("Read `../docs/guide.md`.\n", filename=filename, + existing=("packages/other/docs/guide.md",)), [1]) + self.assertEqual(self.dead_lines("Read `../docs/guide.md`.\n", filename=filename, + existing=("packages/client/docs/guide.md",)), []) + self.assertEqual(self.dead_lines("Read `../docs/guide.md`.\n", filename=filename, + existing=("docs/guide.md",)), []) + + def test_hidden_directory_dot_is_preserved_in_suffix_lookup(self): + self.assertEqual(self.dead_lines("Read `.github/guide.md`.\n", + existing=("packages/client/github/guide.md",)), [1]) + self.assertEqual(self.dead_lines("Read `.github/guide.md`.\n", + existing=("packages/client/.github/guide.md",)), []) + + def test_outside_reference_does_not_become_an_in_repo_suffix_match(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + with patch.object(Path, "exists", side_effect=AssertionError("outside path probed")): + self.assertIsNone(_candidate_exists(root, root / "AGENTS.md", "../src/entry.py", + {"src/entry.py"})) + + def test_path_index_read_failure_is_not_a_partial_success(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + blocked = root / "src" + blocked.mkdir() + original = os.scandir + + def inaccessible(path): + if Path(path) == blocked: + raise PermissionError(13, "sensitive-error-detail", str(blocked)) + return original(path) + + with patch("agent_config_score.scanner.os.scandir", side_effect=inaccessible): + with self.assertRaises(OSError) as caught: + _repository_path_index(root) + self.assertNotIn("sensitive-error-detail", str(caught.exception)) + + def test_unreadable_dependency_tree_remains_pruned(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + (root / "AGENTS.md").write_text("Run tests.\n", encoding="utf-8") + blocked = root / "node_modules" + blocked.mkdir() + original = os.scandir + + def inaccessible(path): + if Path(path) == blocked: + raise PermissionError(13, "excluded dependency tree") + return original(path) + + with patch("agent_config_score.scanner.os.scandir", side_effect=inaccessible): + index = _repository_path_index(root) + self.assertIn("AGENTS.md", index) + self.assertNotIn("node_modules", index) + def test_dependency_tree_is_pruned_before_descent(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory).resolve()