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

- 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.

- 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.
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,14 @@ The `$schema` annotation enables live validation and completion in editors that

The schema is Draft 2020-12 and lives at `schema/agentconfigscore.schema.json`. AgentConfigScore's tests verify that its suppression rule enum stays exactly aligned with the stable Rule Catalog, so editor hints cannot silently drift away from scanner behavior.

Unreleased source builds additionally reject duplicate JSON member names at any
depth, even if the values match or a name uses JSON escapes. A repeated threshold
is a configuration error, not "last value wins." `NaN`/`Infinity` constants and a
Boolean format `version` are rejected too. Keep one reviewed value per field;
do not rely on editor schema validation to detect duplicate names. See
[configuration failure behavior](docs/doctor.md#configuration-decoding). These
changes are not in the published v0.24.0 artifacts.

Policy fields:

| Key | Default | Meaning |
Expand Down
33 changes: 33 additions & 0 deletions docs/doctor.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,39 @@ 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.

## Configuration decoding

Unreleased source builds use one shared configuration decoder for scans,
`feedback`, `diff`, `compare` and `doctor`. Every JSON object in
`.agentconfigscore.json` must have unique decoded member names, including nested
policy and suppression objects. Repeating a name is an error even when both
values match; `fail_under` and `fail\u005funder` are the same decoded name.
Names reused in separate objects are not duplicates. Remove the duplicate after
reviewing which value is intended; no automatic rewrite or first/last-value
selection is performed.

The decoder also rejects the non-standard number constants `NaN`, `Infinity`
and `-Infinity`. The format `version` cannot be a Boolean; numeric `1` (including
the previously supported `1.0`) and an omitted version retain their behavior.
Duplicate/constant diagnostics do not echo raw member names or values. Ordinary
JSON syntax failures still report a line and column. Editor JSON Schema checks
may see only an already-decoded object and are not a substitute for this check.

Invalid configuration returns `2` from scans, `feedback`, `diff` and `compare`,
without a successful report or overwriting a requested report destination.
`doctor --json` instead reports `ok: false` and a `config` error, with exit `1`.
Both baseline and candidate configurations must validate even when trusted CLI
threshold overrides are supplied. `doctor` derives policy and schema metadata
from one read, not two potentially different reads. Missing config still uses
compatibility defaults and produces an advisory doctor warning.

This is configuration validation, not an atomic repository snapshot, filesystem
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.

## Instruction and repository access

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
Expand Down
11 changes: 11 additions & 0 deletions docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ not an accuracy result or a reproducibility guarantee: repository inputs, policy
runtime and filesystem behavior are outside that fingerprint. Missing source is
explicitly unknown, and historic reports are not relabelled.

## Configuration decoding

Unreleased source builds reject duplicate decoded JSON member names at every
configuration depth, non-standard `NaN`/`Infinity` constants and a Boolean format
`version`. They do not choose the first or last repeated value. Both comparison
trees must validate; CLI threshold overrides cannot authorize malformed config.
`doctor` uses one read for policy and schema metadata. These changes are not in
v0.24.0. See [failure and compatibility details](doctor.md#configuration-decoding).
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.

## Instruction-file filesystem boundary

Selected instruction files and `.agentconfigscoreignore` must resolve to regular
Expand Down
4 changes: 4 additions & 0 deletions docs/simplified/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ AgentConfigScore 的分数衡量已经检测到的、由规则定义的风险,

v0.24.0+ 提供[工具源码身份(英文)](../report-identity.md),用于区分声明同一版本号的不同实现。相同指纹不证明准确率或可复现性:仓库输入、策略、运行环境和文件系统行为不在该指纹范围内。工具源码缺失时明确记为未知,历史报告不补标成当前版本。

## 配置解析

尚未发布的源码拒绝各层配置中解码后重复的 JSON 字段名、非标准 `NaN`/`Infinity` 常量以及布尔类型的格式 `version`,不选择重复字段的第一个或最后一个值。比较的两侧都必须通过验证;命令行门槛覆盖不能批准格式错误的配置。`doctor` 从同一次读取的内容获取策略和 schema 元数据。这些变化不在 v0.24.0 中。[失败行为与兼容性细节(英文)](../doctor.md#configuration-decoding)。这不证明策略充分、不认证作者、不提供原子快照,也不能恢复调用方 JSON 解析时已经丢失的重复字段。

## 指令文件的文件系统边界

选中的指令文件和 `.agentconfigscoreignore` 必须解析为扫描根目录内的普通文件。仓库内的文件符号链接仍受支持;发现位置、Markdown 相对链接和指令作用域使用链接所在的位置,不是共享目标的目录。用户明确指定的根目录别名可用,并以其解析后的目录为边界。
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 @@ -28,6 +28,8 @@ agent-config-score doctor

审核后提交生成的配置和工作流。只想本地使用时选 `init --no-workflow`;标准工作流缺失会成为 doctor 的提示性警告,而不是错误。

尚未发布的源码拒绝配置中重复的字段名(包括嵌套对象和转义拼写)、`NaN`/`Infinity` 常量及布尔类型的格式 `version`,不会悄悄采用覆盖后的值。请审核并为每个字段保留一个值。扫描、`feedback`、`diff` 和 `compare` 返回 `2` 且不生成成功报告;doctor 给出 `config` 错误并返回 `1`。命令行门槛覆盖不能跳过配置验证;缺失配置时的默认行为不变。这些变化不在 v0.24.0 中。[配置解析细节(英文)](../doctor.md#configuration-decoding)。

若没有找到支持的指令文件,请编写符合仓库实际情况的指令,或采用[模板(英文)](../../examples/README.md)。不要仅为涨分添加词句。[完整发现范围(英文)](../../README.md#what-it-scans)。

## 3. 检查当前目录
Expand Down
4 changes: 4 additions & 0 deletions docs/traditional/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ AgentConfigScore 的分數衡量已偵測到、由規則定義的風險,不衡

v0.24.0+ 提供[工具原始碼身分(英文)](../report-identity.md),用來區分宣告同一版本號的不同實作。相同指紋不證明準確率或可重現性:儲存庫輸入、策略、執行環境與檔案系統行為不在該指紋範圍內。工具原始碼缺失時明確記為未知,歷史報告不補標為目前版本。

## 設定解析

尚未發布的原始碼拒絕各層設定中解碼後重複的 JSON 欄位名稱、非標準 `NaN`/`Infinity` 常數以及布林型別的格式 `version`,不選取重複欄位的第一個或最後一個值。比較的兩側都必須通過驗證;命令列門檻覆寫不能核准格式錯誤的設定。`doctor` 從同一次讀取的內容取得策略及 schema 中繼資料。這些變更不在 v0.24.0 中。[失敗行為與相容性細節(英文)](../doctor.md#configuration-decoding)。這不證明策略充分、不認證作者、不提供原子快照,也不能復原呼叫端 JSON 解析時已遺失的重複欄位。

## 指令檔的檔案系統邊界

選取的指令檔與 `.agentconfigscoreignore` 必須解析為掃描根目錄內的一般檔案。儲存庫內的檔案符號連結仍受支援;發現位置、Markdown 相對連結與指令作用域使用連結所在位置,不是共享目標的目錄。使用者明確指定的根目錄別名可用,並以其解析後的目錄為邊界。
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 @@ -28,6 +28,8 @@ agent-config-score doctor

審閱後提交產生的設定與工作流程。只想在本機使用時選 `init --no-workflow`;缺少標準工作流程只會成為 doctor 的提示性警告,不是錯誤。

尚未發布的原始碼拒絕設定中重複的欄位名稱(包括巢狀物件及跳脫拼寫)、`NaN`/`Infinity` 常數與布林型別的格式 `version`,不會悄悄採用覆蓋後的值。請審閱並為每個欄位保留一個值。掃描、`feedback`、`diff` 與 `compare` 返回 `2` 且不產生成功報告;doctor 提供 `config` 錯誤並返回 `1`。命令列門檻覆寫不能略過設定驗證;缺少設定時的預設行為不變。這些變更不在 v0.24.0 中。[設定解析細節(英文)](../doctor.md#configuration-decoding)。

若沒有找到支援的指令檔,請撰寫符合儲存庫實際需求的指令,或使用[範本(英文)](../../examples/README.md)。不要只為提高分數添加詞句。[完整探索範圍(英文)](../../README.md#what-it-scans)。

## 3. 檢查目前目錄
Expand Down
9 changes: 9 additions & 0 deletions docs/translations.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ 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.

On 2026-10-25, built on `10e9b50a6f507ae9edf7a1589fdca704d05cfc07`, both core
guides and limitation summaries were synchronized for strict policy JSON:
duplicate decoded names at every depth, non-standard numeric constants, Boolean
format-version rejection, one-read doctor metadata and unchanged missing-config
defaults. Invalid baseline/candidate configs remain operational failures even
under CLI threshold overrides; validation is not policy adequacy or an atomic
snapshot. This is unreleased source behavior, not a v0.24.0 artifact or Action-ref
update. Executable examples, valid-policy scoring and frozen research 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;
Expand Down
8 changes: 8 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ Commit the generated files after reviewing them. If you only want local checks,
use `init --no-workflow`. A missing standard workflow is then an advisory doctor
warning, not an error.

Unreleased source builds reject duplicate config member names (including nested
objects and escaped spellings), `NaN`/`Infinity` constants and a Boolean format
`version`, rather than choosing an overwritten value. Review and keep one value
per field. Scans, `feedback`, `diff` and `compare` return `2` without a success
report; doctor returns `1` with a `config` error. Threshold overrides do not bypass
config validation. Missing-config defaults remain unchanged. This is not in
v0.24.0; see [configuration decoding](doctor.md#configuration-decoding).

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).
Expand Down
31 changes: 31 additions & 0 deletions scripts/verify_installed_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,37 @@ def verify(python: Path, expected_version: str) -> list[str]:
checks.append("danger regression, compare, baseline policy/suppressions, removal, and worktree cleanup")

instructions.write_text(clean_text, encoding="utf-8")
for invalid_json in (
'{"policy":{"fail_under":90,"fail_under":0}}',
r'{"policy":{"fail_under":90,"fail\u005funder":0}}',
'{"version":true}',
'{"version":NaN}',
'{"version":Infinity}',
'{"version":-Infinity}',
):
config.write_text(invalid_json, encoding="utf-8")
require(run([*cli, ".", "--json"], repo, expected=2) == "",
"Ambiguous/non-standard config emitted a scan")
ambiguous = '{"policy":{"fail_under":90,"fail_under":0}}'
config.write_text(ambiguous, encoding="utf-8")
for command in commands[1:]:
require(run([*command, ".", "--json"], repo, expected=2) == "",
"Alternate entrypoint accepted ambiguous config")
invalid_doctor = json.loads(run([*cli, "doctor", "--json"], repo, expected=1))
require(invalid_doctor["ok"] is False
and any(c["name"] == "config" and c["status"] == "error" for c in invalid_doctor["checks"]),
"Doctor certified ambiguous config")
for args in (["diff", "--json"], ["compare", str(base), str(repo), "--json"]):
require(run([*cli, *args], repo, expected=2) == "", "Invalid config emitted a regression report")
protected_report = workspace / "preserved-feedback.md"
protected_report.write_bytes(b"preserved report")
require(run([*cli, "feedback", "--output", str(protected_report)], repo, expected=2) == "",
"Feedback accepted ambiguous config")
require(protected_report.read_bytes() == b"preserved report" and config.read_text(encoding="utf-8") == ambiguous,
"Config rejection overwrote an input or report")
config.write_bytes(original[0])
checks.append("strict policy JSON across aliases, module, doctor, diff, compare and feedback without success reports or overwrites")

config.write_bytes(b'{"private":"synthetic-\xff"}')
require(run([*cli, ".", "--json"], repo, expected=2) == "", "Invalid config emitted a scan")
config.write_bytes(original[0])
Expand Down
39 changes: 34 additions & 5 deletions src/agent_config_score/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ def parse_policy(data: object, *, today: date | None = None) -> Policy:
raise ConfigError("$schema must be a non-empty string when provided")

version = data.get("version", 1)
if version != 1:
if isinstance(version, bool) or version != 1:
raise ConfigError("version must be 1")

raw_policy = data.get("policy", {})
Expand Down Expand Up @@ -179,15 +179,38 @@ def parse_policy(data: object, *, today: date | None = None) -> Policy:
)


def load_policy(root: Path) -> Policy:
def _unique_json_object(pairs: list[tuple[str, object]]) -> dict:
result = {}
for name, value in pairs:
if name in result:
# Member names as well as values can contain private material.
raise ConfigError("duplicate JSON member names are not allowed; keep one value per field")
result[name] = value
return result


def _reject_nonstandard_number(_value: str) -> None:
raise ConfigError("non-standard JSON number constants are not allowed")


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():
return Policy()
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"))
data = json.loads(
path.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:
Expand All @@ -196,8 +219,14 @@ def load_policy(root: Path) -> Policy:
raise ConfigError(
f"invalid JSON in {CONFIG_NAME} at line {exc.lineno}, column {exc.colno}: {exc.msg}"
) from exc
except ConfigError as exc:
raise ConfigError(f"invalid JSON in {CONFIG_NAME}: {exc}") from exc

try:
return parse_policy(data)
return parse_policy(data), data
except ConfigError as exc:
raise ConfigError(f"invalid {CONFIG_NAME}: {exc}") from exc


def load_policy(root: Path) -> Policy:
return _load_policy_document(root)[0]
28 changes: 10 additions & 18 deletions src/agent_config_score/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@

from dataclasses import asdict, dataclass
from datetime import datetime, timezone
import json
from pathlib import Path

from .config import CONFIG_NAME, SCHEMA_URL, ConfigError, load_policy
from .config import CONFIG_NAME, SCHEMA_URL, ConfigError, _load_policy_document
from .gitdiff import GitError, detect_base_ref, repository_root
from .scanner import _read_scanner_source, _repository_path_index, discover

Expand Down Expand Up @@ -49,28 +48,21 @@ def to_dict(self) -> dict:

def _check_config(root: Path) -> tuple[list[DoctorCheck], object | None]:
checks: list[DoctorCheck] = []
path = root / CONFIG_NAME
if not path.exists():
try:
policy, raw = _load_policy_document(root)
except ConfigError as exc:
checks.append(DoctorCheck("config", "error", f"Invalid {CONFIG_NAME}: {exc}"))
return checks, None
except OSError:
checks.append(DoctorCheck("config", "error", f"Could not read {CONFIG_NAME}; check filesystem access."))
return checks, None
if raw is None:
checks.append(DoctorCheck(
"config",
"warning",
f"{CONFIG_NAME} is missing; compatibility defaults will be used.",
))
return checks, None
if not path.is_file():
checks.append(DoctorCheck("config", "error", f"{CONFIG_NAME} exists but is not a file."))
return checks, None

try:
policy = load_policy(root)
raw = json.loads(path.read_text(encoding="utf-8"))
except UnicodeError:
checks.append(DoctorCheck("config", "error", f"{CONFIG_NAME} must be saved as UTF-8."))
return checks, None
except (OSError, json.JSONDecodeError, ConfigError) as exc:
checks.append(DoctorCheck("config", "error", f"Invalid {CONFIG_NAME}: {exc}"))
return checks, None

checks.append(DoctorCheck(
"config",
"pass",
Expand Down
Loading
Loading