feat(hermes): make the fix categories an operator setting
All checks were successful
Tests / Declarative: Post Actions passed: 1237

The three defect categories were implicit in code: an operator could neither
see which were active nor switch one off. ARIADNE_HERMES_FIX_CATEGORIES now
names them, an unrecognised entry is ignored rather than trusted, and an empty
setting means all three so the default stays obvious.

Kept separate from ARIADNE_HERMES_ALLOWED_ACTIONS on purpose. That allowlist
gates what Ariadne executes on its own authority, with nothing between the
decision and the change. Everything here becomes a pull request a person
reads. Sharing one list would let a patch clear the same gate as an autonomous
mutation, and that distinction is what makes the autonomous half defensible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
codex 2026-08-06 20:46:27 -03:00
parent c6aa448ee8
commit 54de192c8b
6 changed files with 80 additions and 11 deletions

View File

@ -241,20 +241,47 @@ def _is_writable(path: str, cfg: dict) -> bool:
return bool(prefixes) and any(path.startswith(p) for p in prefixes)
def extract_defects(bundle: dict, cfg: dict) -> list[dict[str, Any]]:
"""Return every mechanically-fixable defect the evidence names.
ALL_CATEGORIES: tuple[str, ...] = (LINT_VIOLATION, UNDEFINED_NAME, FAILING_ASSERTION)
Inputs: the evidence bundle and the per-job code cfg. Outputs: the lint,
undefined-name and failing-assertion defects together, most precisely
located first, so the instruction leads with the defect whose fix is least
open to interpretation.
def enabled_categories(cfg: dict) -> tuple[str, ...]:
"""Return the defect categories this deployment allows Hermes to be asked about.
Inputs: the per-job code cfg, whose `fix_categories` names the enabled
ones. Outputs: the recognised subset, in declaration order.
This is the operator's control over what Hermes may be pointed at. It is
deliberately separate from the action allowlist: that gates what Ariadne
executes on its own, whereas everything here becomes a pull request a
person reads. An unrecognised name is ignored rather than trusted, and an
empty setting means every category, so the default stays obvious.
"""
return (
extract_lint_defects(bundle, cfg)
+ extract_name_defects(bundle, cfg)
+ extract_assertion_defects(bundle, cfg)
)
raw = cfg.get("fix_categories") if isinstance(cfg, dict) else None
names = {str(item).strip() for item in (raw or []) if str(item).strip()}
if not names:
return ALL_CATEGORIES
return tuple(category for category in ALL_CATEGORIES if category in names)
def extract_defects(bundle: dict, cfg: dict) -> list[dict[str, Any]]:
"""Return every enabled mechanically-fixable defect the evidence names.
Inputs: the evidence bundle and the per-job code cfg. Outputs: the enabled
categories' defects together, most precisely located first, so the
instruction leads with the defect whose fix is least open to
interpretation.
"""
enabled = enabled_categories(cfg)
found: list[dict[str, Any]] = []
if LINT_VIOLATION in enabled:
found += extract_lint_defects(bundle, cfg)
if UNDEFINED_NAME in enabled:
found += extract_name_defects(bundle, cfg)
if FAILING_ASSERTION in enabled:
found += extract_assertion_defects(bundle, cfg)
return found
def defect_instruction(defects: list[dict[str, Any]]) -> str:

View File

@ -48,6 +48,7 @@ def build_config(config: Any) -> dict[str, Any]:
"owner": config.hermes_code_owner,
"repo": config.hermes_code_repo,
"max_open_proposals": getattr(config, "hermes_code_max_open_proposals", 0),
"fix_categories": list(getattr(config, "hermes_fix_categories", None) or []),
"base_branch": config.hermes_code_base_branch,
"timeout_seconds": _GITEA_TIMEOUT_SECONDS,
"legacy_job": str(getattr(config, "hermes_code_job", "") or ""),

View File

@ -193,6 +193,7 @@ class Settings:
hermes_max_branches: int
hermes_job_namespaces: dict
hermes_code_max_open_proposals: int
hermes_fix_categories: list
hermes_max_actions_per_incident: int
hermes_api_url: str
hermes_api_key: str

View File

@ -41,6 +41,9 @@ def _hermes_autotriage_config() -> dict[str, Any]:
"hermes_max_branches": _env_int("ARIADNE_HERMES_MAX_BRANCHES", 5),
"hermes_job_namespaces": _pair_map(_env("ARIADNE_HERMES_JOB_NAMESPACES", "")),
"hermes_code_max_open_proposals": _env_int("ARIADNE_HERMES_CODE_MAX_OPEN_PROPOSALS", 64),
"hermes_fix_categories": [
item.strip() for item in _env("ARIADNE_HERMES_FIX_CATEGORIES", "").split(",") if item.strip()
],
"hermes_max_actions_per_incident": _env_int("ARIADNE_HERMES_MAX_ACTIONS_PER_INCIDENT", 1),
"hermes_api_url": _env(
"ARIADNE_HERMES_API_URL",

View File

@ -271,3 +271,39 @@ def test_a_lint_defect_with_a_disallowed_suffix_is_dropped() -> None:
cfg = {"allowed_path_prefixes": ["ariadne/"], "allowed_suffixes": [".go"]}
assert module.extract_lint_defects(_bundle("ariadne/app.py:1:1: E501 Long"), cfg) == []
# --- the operator's control over what Hermes may be asked to fix ---------------
def test_no_setting_means_every_category() -> None:
"""The default must be obvious rather than silently empty."""
assert module.enabled_categories({}) == module.ALL_CATEGORIES
assert module.enabled_categories({"fix_categories": []}) == module.ALL_CATEGORIES
assert module.enabled_categories(None) == module.ALL_CATEGORIES
def test_a_category_can_be_switched_off() -> None:
cfg = {**CFG, "fix_categories": [module.LINT_VIOLATION]}
assert module.enabled_categories(cfg) == (module.LINT_VIOLATION,)
bundle = {
"jenkins": {
"console_failures": [{"text": "ariadne/app.py:12:5: E501 Line too long"}],
"console_tail": "",
"failed_tests": [{"name": "t", "className": "c", "errorDetails": "boom"}],
}
}
cats = {d["category"] for d in module.extract_defects(bundle, cfg)}
assert cats == {module.LINT_VIOLATION}
def test_an_unrecognised_category_is_ignored_not_trusted() -> None:
cfg = {"fix_categories": ["lint_violation", "rewrite_everything"]}
assert module.enabled_categories(cfg) == (module.LINT_VIOLATION,)
def test_categories_keep_their_declared_order() -> None:
cfg = {"fix_categories": [module.FAILING_ASSERTION, module.LINT_VIOLATION]}
assert module.enabled_categories(cfg) == (module.LINT_VIOLATION, module.FAILING_ASSERTION)

View File

@ -46,6 +46,7 @@ def _code_cfg(**overrides) -> dict: # type: ignore[no-untyped-def]
"owner": "bstein",
"repo": "hermes-code-demo",
"max_open_proposals": 0,
"fix_categories": [],
"base_branch": "master",
"timeout_seconds": 15.0,
"legacy_job": JOB,