Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

### Fixed

- Keep explicit guardrail review scoped to the selected file instead of requiring whole-repository instruction discovery first. Unselected broken/external/nonregular sources and unrelated subtrees no longer block a valid review; relevant ignore rules, selected ancestry, supported filenames and canonical filename preference still apply. Directory aliases or ambiguous directory casing require manual integration. Full scans/doctor still reject unreadable sources; confirmation, append guards, scoring and published v0.24.0 artifacts are unchanged.

- Preserve per-repository smoke results and partial progress after acquisition or scanner operational failures instead of aborting and losing earlier rows. Error cases have null scores/findings; acquisition failures never match a clean expectation. Explicitly document the pinned Reaparr dangling instruction symlink as an expected typed operational rejection, separate from the historical clean root-file example. Expectation matching is not all-scans-completed or accuracy.

- Check simple full/collapsed Markdown reference links and images against document-relative local targets, reporting a used target at its definition line. Respect document-local, first-definition precedence, skip unused definitions and avoid treating supported definitions/link captions as extra prose paths. Code, ambiguous containers/layouts and unsupported Markdown forms retain explicit coverage limits. Stable rule IDs/penalties, runtime dependencies, published v0.24.0 artifacts and frozen research records are unchanged.
Expand Down
10 changes: 10 additions & 0 deletions docs/guardrail-suggestions.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ does not create missing instruction files or silently choose another agent's
configuration. Paths must be repository-relative and cannot escape the root.
The target must be a regular in-repository UTF-8 file of at most 1 MiB.

Unreleased source builds select this exact file without walking the whole tree.
They inspect the relevant ignore file and selected ancestry/parent names, keeping
supported-file, ignored/pruned-directory and canonical filename-preference rules.
An unselected broken or external instruction link does not block this local
review and is not opened or repaired. This is not in v0.24.0. Directory aliases,
Windows reparse ancestors and ambiguous directory casing require manual
integration rather than guessing a discovery scope. Relevant selection I/O
failures remain errors. Normal scans and `doctor` still check the whole tree
and reject unsafe/unreadable sources; review success cannot bypass that gate.

The preview reads but does not modify files or save a plan. It reports evidence
line numbers/kinds without quoting old instruction text, and otherwise proposes
a block requiring explicit user approval before deleting existing files,
Expand Down
9 changes: 9 additions & 0 deletions docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,15 @@ whole selected file. Narrow approval-bypass directives also require manual
resolution even alongside recognized protection; they are unscored review
signals, not complete conflict detection or evidence of actual agent behavior.

Unreleased source builds scope explicit guardrail selection without whole-tree
discovery. Only relevant ignore rules and the selected ancestry/parent names are
inspected for selection; unselected broken/external/nonregular instruction files
are not validated or repaired. Directory aliases/reparse ancestors and ambiguous
directory casing require manual integration. This does not change full scans or
doctor: an unsafe source elsewhere still stops the whole-tree check. Selected
review/append success is not a clean repository result. This refinement is not
in v0.24.0; confirmation and non-atomic append boundaries remain unchanged.

The scanner intentionally does not claim to understand arbitrary prose. These
inputs can still receive A 100 when they avoid every known deterministic rule:

Expand Down
2 changes: 2 additions & 0 deletions docs/simplified/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ v0.24.0+ 的 `--explain` 和[维护者清单(英文)](../best-practices.md)

v0.24.0+ 的 `init --workflow-only` 将策略排除在读写范围外,即使使用 `--force` 也不会重置策略,但也不会验证策略。初始化选定目标若涉及链接、reparse point、共享文件或非普通文件,须手动接入。独占创建和已打开文件检查减少误写风险,不代表完全消除文件系统竞争;初始化不是原子事务,写入失败可能留下部分文件。生成的 `@v0` 不一定与本地 CLI 源码相同。

尚未发布的源码不再为单文件保护审阅先遍历整仓,仅检查相关忽略规则、所选路径的祖先目录及父目录名称。无关的坏链接、外部或非普通指令文件不在本次验证/修复范围内;目录别名、reparse 祖先及大小写有歧义的目录须手动接入。整仓扫描和 doctor 仍会因不安全来源报错,单文件审阅/追加成功不代表整仓检查通过。此修正不在 v0.24.0 中,确认及非原子追加边界不变。

## 评测尚未证明什么

维护的离线对抗用例目前有 474/474 个确定性契约匹配;公开挑战中检出 6/8,仍保留两个语义漏报。这是封闭开发用例的回归结果,**不是现实仓库准确率**。修复已知的虚假成功报告和环境上传案例是开发进展,不是独立准确率估计。
Expand Down
2 changes: 2 additions & 0 deletions docs/simplified/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ agent-config-score guardrails . --file AGENTS.md --template-language simplified

明确标注的示例章节不算有效策略;如果该章节延续到文件末尾,就不能自动追加。少数明确要求跳过删除确认的句式会产生只含行号与类型的 `review_signals`,即使其他位置已识别到保护,也不能自动追加。请先手动处理这些策略;这不是通用的矛盾检测器。

尚未发布的源码可直接选择 `--file`,不先遍历整仓,因此无关指令文件的坏链接不会阻止这次单文件审阅。相关忽略规则和所选路径的祖先目录检查仍生效;目录别名或大小写有歧义的目录须手动接入。其他文件不会因此被验证或修复,普通扫描/doctor 的失败仍须单独处理;追加成功不代表整仓扫描完整或安全。此范围修正不在 v0.24.0 包中。

## 可选:面向维护者的具体建议(v0.24.0+)

```bash
Expand Down
2 changes: 2 additions & 0 deletions docs/traditional/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ v0.24.0+ 的 `--explain` 和[維護者清單(英文)](../best-practices.md)

v0.24.0+ 的 `init --workflow-only` 將策略排除在讀寫範圍外,即使使用 `--force` 也不會重設策略,但也不會驗證策略。初始化選定目標若涉及連結、reparse point、共用檔案或非一般檔案,須手動整合。獨佔建立與已開啟檔案檢查減少誤寫風險,不代表完全消除檔案系統競爭;初始化不是原子交易,寫入失敗可能留下部分檔案。產生的 `@v0` 不一定與本機 CLI 原始碼相同。

尚未發布的原始碼不再為單檔保護審閱先遍歷整個儲存庫,僅檢查相關排除規則、所選路徑的祖先目錄及父目錄名稱。無關的失效連結、外部或非一般指令檔不在本次驗證/修復範圍內;目錄別名、reparse 祖先及大小寫有歧義的目錄須手動整合。整個儲存庫的掃描及 doctor 仍會因不安全來源報錯,單檔審閱/追加成功不代表整體檢查通過。此修正不在 v0.24.0 中,確認及非原子追加界線不變。

## 評測尚未證明什麼

持續維護的離線對抗案例目前有 474/474 個確定性契約符合預期;公開挑戰中偵測到 6/8,仍保留兩個語意漏報。這是封閉開發案例的回歸結果,**不是實際儲存庫的準確率**。修正已知的虛假成功回報與環境上傳案例是開發進展,不是獨立準確率估計。
Expand Down
2 changes: 2 additions & 0 deletions docs/traditional/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ agent-config-score guardrails . --file AGENTS.md --template-language simplified

明確標示的範例章節不算有效策略;如果該章節延續到檔案末尾,就不能自動追加。少數明確要求略過刪除確認的句式會產生只含行號與類型的 `review_signals`,即使其他位置已識別到保護,也不能自動追加。請先手動處理這些策略;這不是通用的矛盾偵測器。

尚未發布的原始碼可直接選擇 `--file`,不先遍歷整個儲存庫,因此無關指令檔的失效連結不會阻止這次單檔審閱。相關排除規則及所選路徑的祖先目錄檢查仍生效;目錄別名或大小寫有歧義的目錄須手動整合。其他檔案不會因此被驗證或修復,一般掃描/doctor 的失敗仍須另行處理;追加成功不代表整個儲存庫已完整掃描或安全。此範圍修正不在 v0.24.0 套件中。

## 選用:面向維護者的具體建議(v0.24.0+)

```bash
Expand Down
7 changes: 7 additions & 0 deletions docs/translations.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ handling and unsupported Markdown/code/layout boundaries. This is an unreleased
source update, not a change to v0.24.0 packages, tags, rolling Action refs or
frozen research evidence. Executable guide examples are unchanged.

On 2026-10-24, built on `8c1747be614a79692d07882d2c7841fb0ad83543`, both core
guides and limitation summaries were synchronized for scoped guardrail file
selection: no whole-tree traversal, relevant ignore/ancestor checks, manual-only
directory aliases/case ambiguity and unchanged full-scan/doctor failure behavior.
This is unreleased source behavior, not a v0.24.0 artifact change. Executable
guide examples, scoring, confirmation and append boundaries 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;
Expand Down
8 changes: 8 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,14 @@ policies manually; this is not a general contradiction detector.
See [guardrail suggestions](guardrail-suggestions.md) for grammar, scope, privacy
and non-atomic/partial-write limits. Existing scoring and CI defaults are unchanged.

Unreleased source builds resolve `--file` without whole-tree discovery, so an
unrelated broken instruction link does not block this selected-file review.
Relevant ignore rules and selected ancestry still apply; directory aliases or
ambiguous directory casing need manual integration. Other sources are not
certified or repaired. Normal scan/doctor failures still need separate attention;
an append does not turn an incomplete whole-tree scan into a clean result.
This scope refinement is not in the v0.24.0 package.

## Optional: actionable maintainer results (v0.24.0+)

```bash
Expand Down
29 changes: 29 additions & 0 deletions scripts/verify_installed_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,35 @@ def verify(python: Path, expected_version: str) -> list[str]:
checks.append("unscored guardrail preview, explicit confirmation, append-only preservation and idempotence")
checks.append("guardrail examples, ambiguous layout and opposing approval directives require manual resolution")

selected_repo = workspace / "selected guardrail scope"
selected_repo.mkdir()
selected_source = selected_repo / "AGENTS.md"
selected_original = b"Run tests.\n"
selected_source.write_bytes(selected_original)
dangling = selected_repo / "CLAUDE.md"
has_dangling = False
try:
os.symlink("missing.txt", dangling)
has_dangling = True
except OSError:
pass # Not all Windows hosts permit creating symbolic links.
selected_check = json.loads(run([str(python), "-I", "-c",
"import json; from pathlib import Path; from unittest.mock import patch; "
"from agent_config_score.guardrails import review_guardrails; "
"scope=patch('agent_config_score.scanner.os.walk', side_effect=AssertionError('whole tree walked')); "
"scope.start(); preview=review_guardrails(Path.cwd()); "
"applied=review_guardrails(Path.cwd(), apply=preview.confirmation_token); scope.stop(); "
"print(json.dumps(applied.to_dict()))"
], selected_repo))
require(selected_check["applied"] is True and selected_check["scope"] == "selected_file_only",
"Selected guardrail review walked or certified the whole tree")
require(selected_source.read_bytes().startswith(selected_original), "Selected append replaced existing bytes")
if has_dangling:
require(os.readlink(dangling) == "missing.txt", "Unselected broken source was changed")
require(run([*cli, ".", "--json"], selected_repo, expected=2) == "",
"Selected review weakened full-scan source rejection")
checks.append("installed selected-file guardrail review avoids whole-tree discovery; full scan still rejects broken sources where symlinks are available")

scoped_rules = repo / "packages" / "client" / "AGENTS.md"
scoped_rules.parent.mkdir(parents=True)
instructions.rename(scoped_rules)
Expand Down
8 changes: 4 additions & 4 deletions src/agent_config_score/guardrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import stat

from .provenance import ToolIdentity, capture_tool_identity
from .scanner import _deletion_guardrail_assessment, _instruction_context, _scanner_source_target, discover
from .scanner import _deletion_guardrail_assessment, _instruction_context, _scanner_source_target, _select_instruction_source


class GuardrailError(ValueError):
Expand Down Expand Up @@ -157,9 +157,9 @@ def review_guardrails(root: Path, *, file: str = "AGENTS.md", template_language:
if not root.is_dir():
raise GuardrailError("Repository path must be a directory")
rel = _relative_file(file)
path = root / rel
if path not in discover(root):
raise GuardrailError("Selected file was not discovered: choose an existing, non-ignored supported instruction file with --file")
path = _select_instruction_source(root, rel)
if path is None:
raise GuardrailError("Selected file is unavailable: choose an existing, non-ignored supported instruction file without directory aliases or ambiguous path casing")
target = _scanner_source_target(root, path)
try:
original, info = _read_source(root, path, target)
Expand Down
83 changes: 77 additions & 6 deletions src/agent_config_score/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,81 @@ def _prefer_canonical_name(candidate: Path, current: Path) -> bool:
return candidate.name < current.name


def _supported_instruction_path(rel: str) -> bool:
path = PurePosixPath(rel)
return (path.name.casefold() in TARGET_NAMES_CASEFOLD
or (rel.startswith((".cursor/rules/", ".claude/", ".github/instructions/"))
and path.suffix.lower() in {".md", ".mdc"}))


def _instruction_directory_excluded(rel: str, patterns: list[str]) -> bool:
return PurePosixPath(rel).name in SKIP_DIRS or any(
pattern.endswith("/**") and fnmatch(rel, pattern[:-3]) for pattern in patterns
)


def _select_instruction_source(root: Path, rel: str) -> Path | None:
"""Select an exact file without discovering/validating the whole tree.

Apply the same support, exclusion and filename precedence rules. Only the
selected ancestry/parent names and relevant ignore file are inspected.
Directory aliases and ambiguous directory-case layouts require manual work.
The caller must still validate and safely open the selected file itself.
"""
relative = PurePosixPath(rel)
if (not rel or relative.is_absolute() or PureWindowsPath(rel).drive
or '..' in relative.parts or '\\' in rel or '\x00' in rel
or not _supported_instruction_path(relative.as_posix())):
return None
try:
root = root.resolve(strict=True)
patterns = _ignore_patterns(root)
if _ignored(relative.as_posix(), patterns):
return None
parent = root
for depth, name in enumerate(relative.parts[:-1], 1):
partial = PurePosixPath(*relative.parts[:depth]).as_posix()
if _instruction_directory_excluded(partial, patterns):
return None
directory = parent / name
try:
info = directory.lstat()
except FileNotFoundError:
return None
reparse = getattr(info, 'st_file_attributes', 0) & getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0)
if (not stat.S_ISDIR(info.st_mode) or reparse or directory.is_symlink()
or directory.resolve(strict=True) != directory):
return None
# A whole-tree case-folded winner can depend on another directory.
# Do not inspect that subtree or guess which scope would win.
if sum(entry.name.casefold() == name.casefold() for entry in parent.iterdir()) != 1:
return None
parent = directory
path = root / relative.as_posix()
try:
info = path.lstat()
except FileNotFoundError:
return None
reparse = getattr(info, 'st_file_attributes', 0) & getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0)
if stat.S_ISDIR(info.st_mode) and not reparse:
return None
for entry in parent.iterdir():
if entry == path or entry.name.casefold() != path.name.casefold():
continue
sibling = entry.lstat()
sibling_reparse = (getattr(sibling, 'st_file_attributes', 0)
& getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0))
if stat.S_ISDIR(sibling.st_mode) and not sibling_reparse:
continue
if _prefer_canonical_name(entry, path):
return None
return path
except RuntimeError as exc:
raise OSError(errno.ELOOP, "Unable to select instruction source") from exc
except OSError as exc:
raise OSError(exc.errno, "Unable to select instruction source") from exc


def discover(root: Path) -> list[Path]:
# Keep the caller's path identity instead of resolving filesystem aliases.
# macOS may rewrite /var to /private/var and Windows may expand 8.3 paths;
Expand All @@ -502,9 +577,7 @@ 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"})):
if _supported_instruction_path(rel):
key = rel.casefold()
current = found.get(key)
if current is None or _prefer_canonical_name(path, current):
Expand All @@ -517,9 +590,7 @@ def select(path: Path) -> None:
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
):
if _instruction_directory_excluded(rel, patterns):
continue
if path.is_symlink() or getattr(path, "is_junction", lambda: False)():
select(path) # A link named AGENTS.md must not silently vanish.
Expand Down
Loading
Loading