diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fc24f6..77538f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ ### Fixed +- Validate selected instruction files and `.agentconfigscoreignore` as in-repository regular files before opening contents, and revalidate instruction sources at read time. Internal file aliases keep their discovered scopes/locations; child directory symlinks and Windows junctions are not traversed. Preserve ignore-glob meaning while pruning excluded trees before descent. +- Fail operationally on unsafe, dangling, cyclic or unreadable selected sources instead of skipping them, exposing raw errors, or scoring incomplete text. Doctor checks actual source readability. Keep legacy `read-error` for report/suppression compatibility, not as a bypass for operational failures; unrelated non-instruction link cycles no longer crash path indexing. This is not an atomic filesystem sandbox. - 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. diff --git a/docs/doctor.md b/docs/doctor.md index 3164fab..7074a51 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 selected instruction sources can be safely resolved and read inside the scan root; - 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; @@ -28,6 +29,13 @@ that a file is missing. Excluded dependency/admin trees are not traversed by thi index check. It checks path metadata only, not instruction quality, tracked-file membership, or an atomic snapshot; access can change after the check completes. +The `instructions` check also reads each selected instruction source through the +scanner's regular-file/containment guard, without executing or semantically +evaluating its text. Invalid source links, unreadable instruction contents and an +invalid `.agentconfigscoreignore` become error checks rather than a successful +preflight. Internal file aliases are supported; child directory symlinks/junctions +are not traversed. See the [filesystem boundary and concurrency limits](limitations.md#instruction-file-filesystem-boundary). + For scripts and CI: ```bash diff --git a/docs/limitations.md b/docs/limitations.md index 51b589b..52e36e9 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -10,6 +10,37 @@ a task, or whether an instruction file is generally “good.” **An A 100 result means that no active deterministic rule matched. It is not a semantic quality certification.** +## Instruction-file filesystem boundary + +Selected instruction files and `.agentconfigscoreignore` must resolve to regular +files inside the resolved scan root. Internal file symlinks remain supported; +findings, Markdown links and instruction scopes use the discovered alias's +repository-relative location, not the shared target's directory. An explicitly +supplied root alias is allowed and defines the resolved boundary. + +Discovery does not descend through child directory symlinks or Windows junctions, +even when their targets are internal. Excluded dependency/build trees and +subtrees covered by an ignore pattern ending in `/**` are pruned before descent. +Other ignore patterns retain their filename-matching meaning. A source explicitly +excluded from discovery is not inspected and has no coverage claim. + +Selected external, dangling, cyclic or non-regular file sources fail before their +contents are opened. Sources are revalidated before reading, and the resolved +target is opened. Failed discovery, resolution or instruction-content reads are +operational errors: scan/compare/feedback return `2` without a successful score or +new success report. `doctor` checks source readability and reports an `instructions` +error with exit `1`. Raw target paths and filesystem error details are not echoed +in these source diagnostics. Legacy `read-error` remains in the catalog for old +report/suppression compatibility, but cannot suppress a current operational failure. + +This is **not a filesystem sandbox or an atomic snapshot**. A concurrent process +can still replace parents or targets between validation and open. Hard-link +identity and mount boundaries are not certified. Other explicitly chosen inputs, +configuration/workflow readers and report destinations are separate interfaces; +this guard does not claim to constrain every filesystem operation in the tool. +Non-instruction link cycles are excluded from the path index, not judged safe or +semantically correct. Keep repositories stable while scanning. + ## Command interpretation The `rm-rf`, `git-clean-force`, and `docker-system-prune` rules tokenize bounded diff --git a/docs/simplified/limitations.md b/docs/simplified/limitations.md index d67dbd3..de723cf 100644 --- a/docs/simplified/limitations.md +++ b/docs/simplified/limitations.md @@ -4,6 +4,16 @@ AgentConfigScore 的分数衡量已经检测到的、由规则定义的风险,不衡量模型智力、任务成功率或指令总体质量。**A 100 表示没有命中生效的确定性规则,不是语义质量或安全认证。** +## 指令文件的文件系统边界 + +选中的指令文件和 `.agentconfigscoreignore` 必须解析为扫描根目录内的普通文件。仓库内的文件符号链接仍受支持;发现位置、Markdown 相对链接和指令作用域使用链接所在的位置,不是共享目标的目录。用户明确指定的根目录别名可用,并以其解析后的目录为边界。 + +发现过程不跟随子目录符号链接或 Windows junction,即使目标在仓库内;依赖、构建目录以及明确由 `/**` 忽略模式覆盖的整棵子树会在进入前排除,其他忽略模式保留文件名匹配含义。明确忽略的来源不检查,也没有覆盖声明。 + +选中的越界、失效、循环或非普通文件来源会在打开内容前失败;读取前再次验证,并打开解析后的目标。发现、解析或指令内容读取失败时,扫描、`compare`、`feedback` 返回 `2`,不生成成功分数或新的成功报告;`doctor` 检查实际可读性,给出 `instructions` 错误并返回 `1`。这些来源诊断不回显原始目标路径或系统错误详情。`read-error` 仅为旧报告和旧豁免保留,不能豁免当前操作错误。 + +这不是文件系统沙箱,也不是原子快照:并发进程仍可能在验证与打开之间替换父目录或目标;不认证硬链接身份或挂载边界。其他显式输入、配置或工作流读取器、报告输出是独立接口,这个守卫不约束工具的所有文件系统操作。非指令文件的循环链接从路径索引排除,不代表安全或语义正确。扫描期间应保持仓库稳定。[完整英文边界](../limitations.md#instruction-file-filesystem-boundary)。 + ## 高分不代表没有问题 只要避开已知规则,下列内容仍可能得到 100 分: diff --git a/docs/simplified/user-guide.md b/docs/simplified/user-guide.md index d8c56af..b6ad048 100644 --- a/docs/simplified/user-guide.md +++ b/docs/simplified/user-guide.md @@ -108,6 +108,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` 给出 `instructions` 错误,不要把不完整扫描当成通过。 | | 报告不可写 | 选择可写的文件路径,不是已有目录;检查父目录权限。 | | 无法检测基线 | 运行 `doctor`,然后指定本地已有的默认分支引用。 | | 禁止示例被报为危险指令 | 对照[支持的禁止语法(英文)](../instruction-context.md)检查具体上下文;工具不理解任意语言和改写。 | diff --git a/docs/traditional/limitations.md b/docs/traditional/limitations.md index c59c99a..d521ae7 100644 --- a/docs/traditional/limitations.md +++ b/docs/traditional/limitations.md @@ -4,6 +4,16 @@ AgentConfigScore 的分數衡量已偵測到、由規則定義的風險,不衡量模型智力、任務成功率或指令整體品質。**A 100 表示沒有命中生效的確定性規則,不是語意品質或安全認證。** +## 指令檔的檔案系統邊界 + +選取的指令檔與 `.agentconfigscoreignore` 必須解析為掃描根目錄內的一般檔案。儲存庫內的檔案符號連結仍受支援;發現位置、Markdown 相對連結與指令作用域使用連結所在位置,不是共享目標的目錄。使用者明確指定的根目錄別名可用,並以其解析後的目錄為邊界。 + +探索過程不跟隨子目錄符號連結或 Windows junction,即使目標在儲存庫內;相依套件、建置目錄,以及明確由 `/**` 忽略模式涵蓋的整棵子樹會在進入前排除,其他忽略模式保留檔名匹配的意義。明確忽略的來源不檢查,也沒有涵蓋範圍的聲明。 + +選取的越界、失效、循環或非一般檔案來源會在開啟內容前失敗;讀取前再次驗證,並開啟解析後的目標。探索、解析或指令內容讀取失敗時,掃描、`compare`、`feedback` 返回 `2`,不產生成功分數或新的成功報告;`doctor` 檢查實際可讀性,提供 `instructions` 錯誤並返回 `1`。這些來源診斷不回顯原始目標路徑或系統錯誤細節。`read-error` 僅為舊報告及舊排除設定保留,不能豁免目前的操作錯誤。 + +這不是檔案系統沙箱,也不是原子快照:並行處理程序仍可能在驗證與開啟之間替換父目錄或目標;不認證硬連結身分或掛載邊界。其他明確輸入、設定或工作流程讀取器、報告輸出是獨立介面,這個守衛不約束工具的所有檔案系統操作。非指令檔的循環連結從路徑索引排除,不代表安全或語意正確。掃描期間應保持儲存庫穩定。[完整英文界線](../limitations.md#instruction-file-filesystem-boundary)。 + ## 高分不代表沒有問題 只要避開已知規則,下列內容仍可能得到 100 分: diff --git a/docs/traditional/user-guide.md b/docs/traditional/user-guide.md index 1f429e7..073c3c3 100644 --- a/docs/traditional/user-guide.md +++ b/docs/traditional/user-guide.md @@ -108,6 +108,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` 提供 `instructions` 錯誤,不要將不完整掃描當成通過。 | | 報告無法寫入 | 選擇可寫入的檔案路徑,不是既有目錄;檢查父目錄權限。 | | 無法偵測基準 | 執行 `doctor`,再指定本機已存在的預設分支參照。 | | 禁止範例被判為危險指令 | 對照[支援的禁止語法(英文)](../instruction-context.md)檢查上下文;工具不理解任意語言和改寫。 | diff --git a/docs/translations.md b/docs/translations.md index 1a39ffd..a57fe07 100644 --- a/docs/translations.md +++ b/docs/translations.md @@ -44,6 +44,12 @@ 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. +On 2026-10-15, both guides and limitation summaries were reviewed with the English +instruction-source containment/readability changes built on source revision +`7ced98d3c4569bb21dcdbb83b48f948d8abe9a3c`: internal file aliases, pruned directory +aliases, operational failures, doctor readability checks, legacy `read-error`, +and explicit non-atomic/non-sandbox boundaries. Published artifacts are 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 d2acc12..84793d5 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -146,6 +146,7 @@ Nothing is uploaded. Review and sanitize the draft yourself before sharing. | 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. | +| Scanner source resolution/read error | Inspect selected instruction and ignore-file links locally. They must point to in-repository regular files; fix dangling links, cycles or permissions. `doctor` reports an `instructions` error. Do not treat an incomplete scan as a passing score. | | `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 6a2581f..f34d89f 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 _repository_path_index, discover +from .scanner import _read_scanner_source, _repository_path_index, discover @dataclass(frozen=True) @@ -149,8 +149,10 @@ def diagnose(root: Path) -> DoctorReport: try: files = discover(root) + for path in files: + _read_scanner_source(root, path) except OSError as exc: - checks.append(DoctorCheck("instructions", "error", f"Could not discover instruction files: {exc}")) + checks.append(DoctorCheck("instructions", "error", f"Could not safely read instruction files: {exc}")) files = None if files: checks.append(DoctorCheck( diff --git a/src/agent_config_score/rules.py b/src/agent_config_score/rules.py index fb5a6f1..b67b60f 100644 --- a/src/agent_config_score/rules.py +++ b/src/agent_config_score/rules.py @@ -51,7 +51,7 @@ class PatternRule: Rule("github-token", "error", "secret", 25, "Possible GitHub token", "Flags strings shaped like common GitHub personal, OAuth, user, server, or refresh tokens."), Rule("aws-access-key", "error", "secret", 25, "Possible AWS access key", "Flags strings shaped like AWS access key IDs."), Rule("private-key", "error", "secret", 30, "Private key material detected", "Flags PEM/OpenSSH private-key headers in persistent agent instructions."), - Rule("read-error", "warning", "other", 2, "Instruction file could not be read", "Reports a supported instruction file that could not be read during analysis."), + Rule("read-error", "warning", "other", 2, "Instruction file could not be read", "Legacy finding retained for historical report and suppression compatibility. Current scans fail operationally when a selected instruction file cannot be safely read, rather than scoring an incomplete analysis."), Rule("context-too-large", "warning", "size", 12, "Persistent instruction context is very large", "Reports an instruction file estimated above 8,000 tokens."), Rule("context-large", "warning", "size", 6, "Persistent instruction context is large", "Reports an instruction file estimated above 5,000 tokens."), Rule("dead-path", "warning", "dead", 4, "Referenced repository path does not exist", "Flags repository-like paths referenced by agent instructions when the target path is absent."), diff --git a/src/agent_config_score/scanner.py b/src/agent_config_score/scanner.py index 70b7d15..b740777 100644 --- a/src/agent_config_score/scanner.py +++ b/src/agent_config_score/scanner.py @@ -5,6 +5,7 @@ from dataclasses import asdict, dataclass from fnmatch import fnmatch from pathlib import Path, PurePosixPath, PureWindowsPath +import errno import hashlib import html import ipaddress @@ -12,6 +13,7 @@ import os import re import shlex +import stat from urllib.parse import unquote, urlsplit from .config import Suppression @@ -421,12 +423,47 @@ def _finding(code: str, file: str, line: int | None = None, message: str | None ) +def _scanner_source_target(root: Path, path: Path) -> Path: + """Validate selected file sources without exposing target/error details.""" + try: + boundary = root.resolve(strict=True) + target = path.resolve(strict=True) + except RuntimeError as exc: # pathlib symlink loops on Python 3.10-3.12 + raise OSError(errno.ELOOP, "Unable to safely resolve scanner source") from exc + except OSError as exc: + raise OSError(exc.errno, "Unable to safely resolve scanner source") from exc + if not target.is_relative_to(boundary): + raise OSError(errno.EPERM, "Scanner source must remain inside the repository") + try: + regular = stat.S_ISREG(target.stat().st_mode) + except OSError as exc: + raise OSError(exc.errno, "Unable to inspect scanner source") from exc + if not regular: + raise OSError(errno.EINVAL, "Scanner source must be a regular file") + return target + + +def _read_scanner_source(root: Path, path: Path) -> str: + # Revalidate at read time and open the resolved in-repository target, not + # an alias that may have changed since discovery. This is not an atomic + # filesystem sandbox: concurrent replacement of parents/targets is possible. + target = _scanner_source_target(root, path) + try: + return target.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + raise OSError(exc.errno, "Unable to read scanner source") from exc + + def _ignore_patterns(root: Path) -> list[str]: p = root / ".agentconfigscoreignore" - if not p.exists(): + try: + p.lstat() # A dangling link is not the same as an absent ignore file. + except FileNotFoundError: return [] + except OSError as exc: + raise OSError(exc.errno, "Unable to inspect scanner ignore file") from exc return [ - line.strip() for line in p.read_text(encoding="utf-8", errors="replace").splitlines() + line.strip() for line in _read_scanner_source(root, p).splitlines() if line.strip() and not line.lstrip().startswith("#") ] @@ -454,26 +491,52 @@ def discover(root: Path) -> list[Path]: # callers should still be able to make discovered files relative to the # absolute root they supplied. root = root.absolute() + boundary = root.resolve() patterns = _ignore_patterns(root) found: dict[str, Path] = {} - for path in root.rglob("*"): - rel_path = path.relative_to(root) - rel = rel_path.as_posix() - if any(part in SKIP_DIRS for part in rel_path.parts) or _ignored(rel, patterns): - continue - if not path.is_file(): - continue - if path.name.casefold() in TARGET_NAMES_CASEFOLD: - key = rel.casefold() - current = found.get(key) - if current is None or _prefer_canonical_name(path, current): - found[key] = path - elif rel.startswith((".cursor/rules/", ".claude/", ".github/instructions/")) and path.suffix.lower() in {".md", ".mdc"}: + + def select(path: Path) -> None: + rel = path.relative_to(root).as_posix() + if _ignored(rel, patterns): + return + if (path.name.casefold() in TARGET_NAMES_CASEFOLD + or (rel.startswith((".cursor/rules/", ".claude/", ".github/instructions/")) + and path.suffix.lower() in {".md", ".mdc"})): key = rel.casefold() current = found.get(key) if current is None or _prefer_canonical_name(path, current): found[key] = path - return sorted(found.values(), key=lambda p: p.relative_to(root).as_posix()) + + for directory, dirs, names in os.walk(root, followlinks=False, onerror=_discovery_error): + descend = [] + for name in dirs: + path = Path(directory) / name + rel = path.relative_to(root).as_posix() + # Only /** patterns prove every descendant is excluded. A literal + # directory-name match alone must not change existing glob meaning. + if name in SKIP_DIRS or any( + pattern.endswith("/**") and fnmatch(rel, pattern[:-3]) for pattern in patterns + ): + continue + if path.is_symlink() or getattr(path, "is_junction", lambda: False)(): + select(path) # A link named AGENTS.md must not silently vanish. + continue + target = _repo_candidate(boundary, boundary, rel) + if target != boundary / rel: + select(path) + continue + descend.append(name) + dirs[:] = descend + for name in names: + select(Path(directory) / name) + selected = sorted(found.values(), key=lambda p: p.relative_to(root).as_posix()) + for path in selected: + _scanner_source_target(boundary, path) + return selected + + +def _discovery_error(exc: OSError) -> None: + raise OSError(exc.errno, "Unable to discover instruction files") from exc def estimate_tokens(text: str) -> int: @@ -779,7 +842,14 @@ def _repo_candidate(root: Path, base: Path, candidate: str) -> Path | None: windows_candidate = PureWindowsPath(candidate) if Path(candidate).is_absolute() or windows_candidate.is_absolute() or windows_candidate.drive: return None - target = (base / candidate).resolve(strict=False) + try: + target = (base / candidate).resolve(strict=False) + except RuntimeError: # pathlib symlink loops on Python 3.10-3.12 + return None + except OSError as exc: + if exc.errno == errno.ELOOP or getattr(exc, "winerror", None) == 1921: + return None + raise OSError(exc.errno, "Unable to resolve repository path") from exc try: target.relative_to(root) except ValueError: @@ -923,11 +993,7 @@ def analyze(root: Path, *, suppressions: tuple[Suppression, ...] = ()) -> Report for path in files: rel = path.relative_to(root).as_posix() - try: - text = path.read_text(encoding="utf-8", errors="replace") - except OSError as exc: - findings.append(_finding("read-error", rel, message=str(exc))) - continue + text = _read_scanner_source(root, path) texts[path] = text context = _instruction_context(text) if not text.strip(): diff --git a/tests/test_instruction_filesystem.py b/tests/test_instruction_filesystem.py new file mode 100644 index 0000000..3b576e0 --- /dev/null +++ b/tests/test_instruction_filesystem.py @@ -0,0 +1,331 @@ +import contextlib +import io +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +from agent_config_score.entrypoint import main +from agent_config_score.scanner import _repository_path_index, analyze, discover + + +class InstructionFilesystemTests(unittest.TestCase): + def link(self, destination, target, *, directory=False): + destination.parent.mkdir(parents=True, exist_ok=True) + try: + os.symlink(target, destination, target_is_directory=directory) + except OSError: + self.skipTest("host does not permit symbolic links") + + def invoke(self, args): + stdout, stderr = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + code = main(args) + return code, stdout.getvalue(), stderr.getvalue() + + def test_external_instruction_is_rejected_before_reading_its_contents(self): + with tempfile.TemporaryDirectory() as directory: + workspace = Path(directory).resolve() + root = workspace / "repo" + root.mkdir() + target = workspace / "outside.txt" + target.write_text("external sentinel\n", encoding="utf-8") + self.link(root / "AGENTS.md", target) + reads = [] + original = Path.read_text + + def record(path, *args, **kwargs): + reads.append(path) + return original(path, *args, **kwargs) + + with patch.object(Path, "read_text", new=record): + with self.assertRaises(OSError): + analyze(root) + self.assertNotIn(target, reads) + self.assertNotIn(root / "AGENTS.md", reads) + + def test_internal_instruction_alias_keeps_its_scope_and_report_location(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + target = root / "shared.txt" + target.write_text("[Guide](guide.md)\nRun `rm -rf cache`.\n", encoding="utf-8") + source = root / "packages" / "client" / "AGENTS.md" + self.link(source, target) + (source.parent / "guide.md").write_text("Guide\n", encoding="utf-8") + report = analyze(root) + self.assertEqual(report.files, ["packages/client/AGENTS.md"]) + self.assertFalse(any(f.code == "dead-path" for f in report.findings)) + finding = next(f for f in report.findings if f.code == "rm-rf") + self.assertEqual((finding.file, finding.line), ("packages/client/AGENTS.md", 2)) + + def test_broken_and_cyclic_instruction_links_are_not_clean_results(self): + for kind in ("broken", "cycle", "external_chain"): + with self.subTest(kind=kind), tempfile.TemporaryDirectory() as directory: + workspace = Path(directory).resolve() + root = workspace / "repo" + root.mkdir() + (root / "AGENTS.md").write_text("Run tests.\n", encoding="utf-8") + source = root / "CLAUDE.md" + target = root / "chain.txt" + self.link(source, target) + if kind == "cycle": + self.link(target, source) + elif kind == "external_chain": + outside = workspace / "outside.txt" + outside.write_text("external sentinel\n", encoding="utf-8") + self.link(target, outside) + invocations = ([str(root), "--json"], ["compare", str(root), str(root), "--json"], + ["feedback", str(root), "--output", str(root / "feedback.md")]) + for args in invocations: + code, stdout, stderr = self.invoke(args) + self.assertEqual(code, 2) + self.assertEqual(stdout, "") + self.assertIn("error:", stderr) + self.assertNotIn("Traceback", stderr) + self.assertNotIn("external sentinel", stderr) + self.assertNotIn(str(workspace / "outside.txt"), stderr) + self.assertFalse((root / "feedback.md").exists()) + + def test_external_ignore_file_is_not_read(self): + with tempfile.TemporaryDirectory() as directory: + workspace = Path(directory).resolve() + root = workspace / "repo" + root.mkdir() + (root / "AGENTS.md").write_text("Run tests.\n", encoding="utf-8") + target = workspace / "outside.txt" + target.write_text("external sentinel\n", encoding="utf-8") + source = root / ".agentconfigscoreignore" + self.link(source, target) + with patch.object(Path, "read_text", side_effect=AssertionError("contents read")): + with self.assertRaises(OSError): + discover(root) + + def test_internal_directory_aliases_are_not_traversed(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + actual = root / "real" + actual.mkdir() + (actual / "AGENTS.md").write_text("Run tests.\n", encoding="utf-8") + self.link(root / "alias", actual, directory=True) + self.link(actual / "cycle", root, directory=True) + self.assertEqual(discover(root), [actual / "AGENTS.md"]) + + def test_non_instruction_link_cycle_does_not_crash_path_index(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + (root / "AGENTS.md").write_text("Run tests.\n", encoding="utf-8") + source = root / "loop.txt" + self.link(source, source) + index = _repository_path_index(root) + self.assertNotIn("loop.txt", index) + self.assertEqual(analyze(root).score, 100) + + def test_instruction_read_failure_is_operational_not_a_partial_report(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + source = root / "AGENTS.md" + source.write_text("Run tests.\n", encoding="utf-8") + original = Path.read_text + + def unreadable(path, *args, **kwargs): + if path == source: + raise PermissionError(13, "sensitive-error-detail") + return original(path, *args, **kwargs) + + with patch.object(Path, "read_text", new=unreadable): + code, stdout, stderr = self.invoke([str(root), "--json"]) + doctor_code, doctor_stdout, doctor_stderr = self.invoke(["doctor", str(root), "--json"]) + self.assertEqual(code, 2) + self.assertEqual(stdout, "") + self.assertIn("Unable to read scanner source", stderr) + self.assertNotIn("sensitive-error-detail", stderr) + self.assertEqual((doctor_code, doctor_stderr), (1, "")) + self.assertFalse(json.loads(doctor_stdout)["ok"]) + self.assertNotIn("sensitive-error-detail", doctor_stdout) + + def test_legacy_read_error_suppression_cannot_override_source_failure(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + source = root / "AGENTS.md" + source.write_text("Run tests.\n", encoding="utf-8") + config = {"version": 1, "suppressions": [{"rule": "read-error", "paths": ["**"], + "reason": "Historical report compatibility check", "expires": "2099-01-01"}]} + (root / ".agentconfigscore.json").write_text(json.dumps(config), encoding="utf-8") + original = Path.read_text + + def unreadable(path, *args, **kwargs): + if path == source: + raise PermissionError(13, "sensitive-error-detail") + return original(path, *args, **kwargs) + + with patch.object(Path, "read_text", new=unreadable): + code, stdout, stderr = self.invoke([str(root), "--json"]) + self.assertEqual((code, stdout), (2, "")) + self.assertIn("Unable to read scanner source", stderr) + self.assertNotIn("sensitive-error-detail", stderr) + + def test_directory_alias_named_as_instruction_is_not_silently_ignored(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + target = root / "shared" + target.mkdir() + (root / "AGENTS.md").write_text("Run tests.\n", encoding="utf-8") + self.link(root / "CLAUDE.md", target, directory=True) + with self.assertRaises(OSError): + discover(root) + + def test_unused_case_variant_does_not_override_selected_canonical_file(self): + with tempfile.TemporaryDirectory() as directory: + workspace = Path(directory).resolve() + root = workspace / "repo" + root.mkdir() + (root / "AGENTS.md").write_text("Run tests.\n", encoding="utf-8") + if (root / "agents.md").exists(): + self.skipTest("case-insensitive filesystem") + outside = workspace / "outside.txt" + outside.write_text("Run `rm -rf cache`.\n", encoding="utf-8") + self.link(root / "agents.md", outside) + report = analyze(root) + self.assertEqual(report.files, ["AGENTS.md"]) + self.assertEqual(report.findings, []) + + def test_doctor_exposes_invalid_instruction_links_as_errors(self): + with tempfile.TemporaryDirectory() as directory: + workspace = Path(directory).resolve() + root = workspace / "repo" + root.mkdir() + target = workspace / "outside.txt" + target.write_text("external sentinel\n", encoding="utf-8") + self.link(root / "AGENTS.md", target) + code, stdout, stderr = self.invoke(["doctor", str(root), "--json"]) + self.assertEqual((code, stderr), (1, "")) + report = json.loads(stdout) + self.assertFalse(report["ok"]) + self.assertEqual(next(c for c in report["checks"] if c["name"] == "instructions")["status"], "error") + self.assertNotIn("external sentinel", stdout) + self.assertNotIn(str(target), stdout) + + def test_read_time_guard_rejects_alias_changed_after_discovery(self): + with tempfile.TemporaryDirectory() as directory: + workspace = Path(directory).resolve() + root = workspace / "repo" + root.mkdir() + inside, outside = root / "shared.txt", workspace / "outside.txt" + inside.write_text("Run tests.\n", encoding="utf-8") + outside.write_text("external sentinel\n", encoding="utf-8") + source = root / "AGENTS.md" + self.link(source, inside) + reads = [] + original = Path.read_text + + def swapped(scan_root): + selected = discover(scan_root) + source.unlink() # Only this test-owned alias, not its target. + self.link(source, outside) + return selected + + def record(path, *args, **kwargs): + reads.append(path) + return original(path, *args, **kwargs) + + with patch("agent_config_score.scanner.discover", side_effect=swapped), \ + patch.object(Path, "read_text", new=record): + code, stdout, stderr = self.invoke([str(root), "--json"]) + self.assertEqual((code, stdout), (2, "")) + self.assertNotIn(outside, reads) + self.assertNotIn(source, reads) + self.assertNotIn("external sentinel", stderr) + + def test_ignored_external_instruction_is_not_selected_or_read(self): + with tempfile.TemporaryDirectory() as directory: + workspace = Path(directory).resolve() + root = workspace / "repo" + root.mkdir() + (root / "AGENTS.md").write_text("Run tests.\n", encoding="utf-8") + (root / ".agentconfigscoreignore").write_text("CLAUDE.md\n", encoding="utf-8") + outside = workspace / "outside.txt" + outside.write_text("Run `rm -rf cache`.\n", encoding="utf-8") + self.link(root / "CLAUDE.md", outside) + report = analyze(root) + self.assertEqual(report.files, ["AGENTS.md"]) + self.assertEqual(report.findings, []) + + def test_internal_ignore_alias_works_and_broken_ignore_alias_fails(self): + for exists in (True, False): + with self.subTest(exists=exists), tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + (root / "AGENTS.md").write_text("Run tests.\n", encoding="utf-8") + target = root / "shared-ignore.txt" + if exists: + target.write_text("CLAUDE.md\n", encoding="utf-8") + self.link(root / ".agentconfigscoreignore", target) + if exists: + self.assertEqual(discover(root), [root / "AGENTS.md"]) + else: + with self.assertRaises(OSError): + discover(root) + + def test_directory_glob_prunes_but_literal_directory_match_keeps_meaning(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + nested = root / "package" + nested.mkdir() + for parent in (root, nested): + (parent / "AGENTS.md").write_text("Run tests.\n", encoding="utf-8") + ignore = root / ".agentconfigscoreignore" + ignore.write_text("package\n", encoding="utf-8") + self.assertEqual(discover(root), [root / "AGENTS.md", nested / "AGENTS.md"]) + ignore.write_text("package/**\n", encoding="utf-8") + original = os.scandir + + def protected(path): + if Path(path) == nested: + raise AssertionError("ignored tree was traversed") + return original(path) + + with patch("agent_config_score.scanner.os.scandir", side_effect=protected): + self.assertEqual(discover(root), [root / "AGENTS.md"]) + + def test_discovery_keeps_the_callers_root_alias_identity(self): + with tempfile.TemporaryDirectory() as directory: + workspace = Path(directory).resolve() + root = workspace / "actual" + nested = root / "package" + nested.mkdir(parents=True) + (nested / "AGENTS.md").write_text("Run tests.\n", encoding="utf-8") + alias = workspace / "alias" + self.link(alias, root, directory=True) + self.assertEqual(discover(alias), [alias / "package" / "AGENTS.md"]) + + @unittest.skipUnless(hasattr(os, "mkfifo"), "FIFO fixtures require POSIX") + def test_instruction_fifo_is_rejected_without_opening_it(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + os.mkfifo(root / "AGENTS.md") + with patch.object(Path, "read_text", side_effect=AssertionError("FIFO opened")): + with self.assertRaises(OSError): + analyze(root) + + @unittest.skipUnless(os.name == "nt", "junction fixture requires Windows") + def test_windows_external_directory_junction_is_not_traversed(self): + with tempfile.TemporaryDirectory() as directory: + workspace = Path(directory).resolve() + root, outside = workspace / "repo", workspace / "outside" + root.mkdir() + outside.mkdir() + (root / "AGENTS.md").write_text("Run tests.\n", encoding="utf-8") + (outside / "AGENTS.md").write_text("Run `rm -rf cache`.\n", encoding="utf-8") + alias = root / "linked" + result = subprocess.run(["cmd", "/d", "/c", "mklink", "/J", str(alias), str(outside)], + capture_output=True, timeout=10) + if result.returncode != 0: + self.skipTest("host does not permit junction creation") + self.assertEqual(discover(root), [root / "AGENTS.md"]) + self.assertEqual(analyze(root).findings, []) + + +if __name__ == "__main__": + unittest.main()