arkaos 4.16.0 → 4.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ """`npx arkaos shield` — the operator-facing surface of the harness scanner.
2
+
3
+ Two roots matter and both are scanned by default: the user config
4
+ (``~/.claude``) and the project the operator is standing in. Splitting
5
+ them would be a footgun — the MCP servers that run with the agent's
6
+ permissions usually live in the project's ``.mcp.json``, while the
7
+ permissions themselves live in the user settings. A tool that reports a
8
+ clean bill of health because it only looked at one of them is worse than
9
+ no tool.
10
+
11
+ Exit codes: 0 = grade A/B, 1 = grade C/D, 2 = grade F or a CRITICAL
12
+ finding. CI can gate on it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from core.governance.harness_scanner import (
23
+ PENALTY,
24
+ Finding,
25
+ ScanReport,
26
+ Severity,
27
+ scan,
28
+ )
29
+
30
+ _ICON = {
31
+ Severity.CRITICAL: "✗",
32
+ Severity.HIGH: "✗",
33
+ Severity.MEDIUM: "⚠",
34
+ Severity.LOW: "·",
35
+ }
36
+
37
+
38
+ def merge(reports: list[ScanReport]) -> ScanReport:
39
+ """One grade for the operator, not one per file they must add up."""
40
+ merged = ScanReport(root=reports[0].root if reports else Path("."))
41
+ for report in reports:
42
+ merged.files_scanned += report.files_scanned
43
+ for finding in report.findings:
44
+ merged.findings.append(_qualify(finding, report.root))
45
+ merged.findings.sort(key=lambda f: (-PENALTY[f.severity], f.rule))
46
+ return merged
47
+
48
+
49
+ def _qualify(finding: Finding, root: Path) -> Finding:
50
+ """Name the root in `where` so two scans cannot be confused."""
51
+ return Finding(
52
+ rule=finding.rule,
53
+ severity=finding.severity,
54
+ where=f"{root}/{finding.where}",
55
+ detail=finding.detail,
56
+ fix=finding.fix,
57
+ )
58
+
59
+
60
+ def _plural(count: int, noun: str) -> str:
61
+ return f"{count} {noun}" if count == 1 else f"{count} {noun}s"
62
+
63
+
64
+ def render(report: ScanReport) -> str:
65
+ scanned = _plural(report.files_scanned, "config file")
66
+ lines = ["", f" ArkaOS Shield — {scanned} scanned", ""]
67
+ if not report.findings:
68
+ lines += [f" Grade {report.grade} ({report.score}/100) — nothing "
69
+ f"to report.", ""]
70
+ return "\n".join(lines)
71
+ for severity in Severity:
72
+ found = report.by_severity(severity)
73
+ if not found:
74
+ continue
75
+ lines.append(f" {severity.value.upper()}")
76
+ for finding in found:
77
+ lines.append(f" {_ICON[severity]} {finding.rule} "
78
+ f"({finding.where})")
79
+ lines.append(f" {finding.detail}")
80
+ lines.append(f" fix: {finding.fix}")
81
+ lines.append("")
82
+ summary = _plural(len(report.findings), "finding")
83
+ lines += [f" Grade {report.grade} ({report.score}/100) — {summary}.", ""]
84
+ return "\n".join(lines)
85
+
86
+
87
+ def exit_code(report: ScanReport) -> int:
88
+ # grade already collapses to F on any CRITICAL, so the letter is the
89
+ # single source of the exit contract.
90
+ if report.grade == "F":
91
+ return 2
92
+ return 1 if report.grade in ("C", "D") else 0
93
+
94
+
95
+ def _roots(args: argparse.Namespace) -> list[Path]:
96
+ if args.path:
97
+ return [Path(p).expanduser() for p in args.path]
98
+ return [Path.home() / ".claude", Path.cwd()]
99
+
100
+
101
+ def main(argv: list[str] | None = None) -> int:
102
+ parser = argparse.ArgumentParser(
103
+ prog="arkaos shield",
104
+ description=(
105
+ "Scan the agent harness configuration for vulnerabilities: "
106
+ "over-permissive allow rules, secrets in config, hook command "
107
+ "injection, unpinned MCP servers, prompt injection in "
108
+ "instruction files."
109
+ ),
110
+ epilog="Exit: 0 = A/B, 1 = C/D, 2 = F or any CRITICAL finding.",
111
+ )
112
+ parser.add_argument(
113
+ "path", nargs="*",
114
+ help="Config roots to scan. Default: ~/.claude and the cwd.",
115
+ )
116
+ parser.add_argument(
117
+ "--json", action="store_true", dest="as_json",
118
+ help="Machine-readable output.",
119
+ )
120
+ args = parser.parse_args(argv)
121
+
122
+ report = merge([scan(root) for root in _roots(args) if root.is_dir()])
123
+ if args.as_json:
124
+ print(json.dumps(report.to_dict(), indent=2))
125
+ else:
126
+ print(render(report))
127
+ return exit_code(report)
128
+
129
+
130
+ if __name__ == "__main__":
131
+ sys.exit(main())
@@ -220,6 +220,61 @@ def _frontend_gate(
220
220
  return None
221
221
 
222
222
 
223
+ # Config edits only ever land through file_path tools. NotebookEdit
224
+ # addresses a notebook_path, never a linter config, so it is not in the
225
+ # set — a notebook can never be a protected config file.
226
+ _CONFIG_GATED_TOOLS = frozenset({"Write", "Edit", "MultiEdit"})
227
+
228
+
229
+ def _operator_messages(transcript_path: str) -> list[str]:
230
+ """Recent OPERATOR messages, never the agent's own — reading the
231
+ assistant scope would let the guarded actor authorise itself."""
232
+ try:
233
+ from core.workflow.transcript_scope import user_messages_from_path
234
+ return user_messages_from_path(transcript_path)
235
+ except Exception:
236
+ return [] # unreadable → no override found → fail closed
237
+
238
+
239
+ def _config_gate(
240
+ root: str,
241
+ tool_name: str,
242
+ transcript_path: str,
243
+ tool_input: dict,
244
+ ) -> int | None:
245
+ """Config-protection gate. Refuses edits to linter/formatter configs
246
+ so the agent fixes the code instead of weakening the check. Returns 2
247
+ on deny (hard mode), None to continue."""
248
+ if tool_name not in _CONFIG_GATED_TOOLS:
249
+ return None
250
+ if not (Path(root) / "core" / "workflow" / "config_guard.py").is_file():
251
+ return None
252
+ try:
253
+ from core.workflow.config_guard import (
254
+ evaluate,
255
+ is_protected_config,
256
+ mode,
257
+ )
258
+ except Exception:
259
+ return None # config-guard-import-failed → allow (chain contract)
260
+ # Zero-read fast path: only a protected-config edit can ever be
261
+ # denied, and only then is the transcript read for an override.
262
+ file_path = str(tool_input.get("file_path", ""))
263
+ if not is_protected_config(file_path):
264
+ return None
265
+ guard_mode = mode()
266
+ if guard_mode == "off":
267
+ return None
268
+ decision = evaluate(file_path, _operator_messages(transcript_path))
269
+ if decision.allow:
270
+ return None
271
+ message = decision.to_stderr_message()
272
+ if guard_mode == "hard":
273
+ return _deny(message)
274
+ print(message, file=sys.stderr) # warn: surface, do not block
275
+ return None
276
+
277
+
223
278
  def _budget_check(session_id: str) -> tuple[bool, str]:
224
279
  """CostGovernor gate (stdlib-only). Returns (allow, warning)."""
225
280
  try:
@@ -307,6 +362,10 @@ def main(stdin_json: dict | None = None) -> int:
307
362
  if code is not None:
308
363
  return code
309
364
 
365
+ code = _config_gate(root, tool_name, transcript_path, tool_input)
366
+ if code is not None:
367
+ return code
368
+
310
369
  # Fast allow: not a flow-gated tool (Bash stays — classified per-command
311
370
  # by the enforcer via bash_is_effect()).
312
371
  if tool_name not in _FLOW_GATED_TOOLS:
@@ -0,0 +1,108 @@
1
+ """Provenance — supply-chain lineage for any ArkaOS artifact.
2
+
3
+ A skill, an agent, or anything else in the tree either originates in
4
+ ArkaOS or is derived from someone else's work. Third-party lineage is a
5
+ security property, not trivia: a tree with no origin field cannot be
6
+ audited after the fact, and "which licence is this under?" becomes
7
+ unanswerable the moment the author who ported it moves on.
8
+
9
+ This module owns the artifact-agnostic core — the ``Provenance`` model
10
+ and the block validator. The file-format parsers live with the artifact
11
+ that uses them: ``core/skills/provenance.py`` reads a SKILL.md
12
+ frontmatter block, ``core/agents/provenance.py`` reads an agent YAML
13
+ key. Both funnel through ``provenance_from_block`` so the validation
14
+ rules are defined once.
15
+
16
+ Rules:
17
+
18
+ - ``origin`` is a lowercase slug (``^[a-z][a-z0-9-]*$``). ``arkaos`` — or
19
+ no block at all — is first-party and needs nothing else.
20
+ - Any other origin MUST declare ``source`` (an https URL) and
21
+ ``license``. https only: a licence trail that can be MITM'd is not a
22
+ trail.
23
+ - ``arkaos`` carrying a source/licence is an unresolved contradiction and
24
+ is rejected — silence would let a bad port keep its upstream URL while
25
+ claiming to be ours.
26
+ - A block present but incoherent (unknown keys, non-mapping) is an
27
+ ERROR, never a silent fallback to first-party.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import re
33
+
34
+ from pydantic import BaseModel, Field, model_validator
35
+
36
+ FIRST_PARTY = "arkaos"
37
+ METADATA_FIELDS = ("origin", "source", "license")
38
+
39
+ _ORIGIN_SLUG = re.compile(r"^[a-z][a-z0-9-]*$")
40
+ _URL = re.compile(r"^https://\S+$")
41
+
42
+
43
+ class ProvenanceError(ValueError):
44
+ """The provenance block is present but cannot be trusted."""
45
+
46
+
47
+ class Provenance(BaseModel):
48
+ """Where an artifact came from, and under what licence."""
49
+
50
+ origin: str = Field(default=FIRST_PARTY)
51
+ source: str | None = None
52
+ license: str | None = None
53
+
54
+ @model_validator(mode="after")
55
+ def _derived_needs_a_trail(self) -> Provenance:
56
+ if not _ORIGIN_SLUG.match(self.origin):
57
+ raise ValueError(
58
+ f"origin {self.origin!r} is not a lowercase slug "
59
+ f"(^[a-z][a-z0-9-]*$)"
60
+ )
61
+ if self.origin == FIRST_PARTY:
62
+ return self._first_party_carries_no_trail()
63
+ missing = [f for f in ("source", "license") if not getattr(self, f)]
64
+ if missing:
65
+ raise ValueError(
66
+ f"origin {self.origin!r} is third-party — "
67
+ f"{', '.join(missing)} required"
68
+ )
69
+ if not _URL.match(self.source or ""):
70
+ raise ValueError(f"source {self.source!r} is not an https URL")
71
+ return self
72
+
73
+ def _first_party_carries_no_trail(self) -> Provenance:
74
+ """`arkaos` plus a source/licence is a contradiction.
75
+
76
+ Either the artifact is ours and there is nothing to attribute, or
77
+ it is not and the origin is wrong.
78
+ """
79
+ declared = [f for f in ("source", "license") if getattr(self, f)]
80
+ if declared:
81
+ raise ValueError(
82
+ f"origin {FIRST_PARTY!r} is first-party but declares "
83
+ f"{', '.join(declared)} — set a third-party origin or "
84
+ f"drop the field"
85
+ )
86
+ return self
87
+
88
+ @property
89
+ def is_first_party(self) -> bool:
90
+ return self.origin == FIRST_PARTY
91
+
92
+
93
+ def provenance_from_block(block: dict | None) -> Provenance:
94
+ """Validate a metadata mapping into a ``Provenance``.
95
+
96
+ ``None`` (no block) is first-party. A block with keys outside
97
+ origin/source/license is an error — a misspelt field must not be
98
+ silently dropped into a first-party default.
99
+ """
100
+ if block is None:
101
+ return Provenance()
102
+ unknown = sorted(str(k) for k in block if k not in METADATA_FIELDS)
103
+ if unknown:
104
+ raise ProvenanceError(
105
+ f"unknown metadata keys: {', '.join(unknown)} "
106
+ f"(expected {', '.join(METADATA_FIELDS)})"
107
+ )
108
+ return Provenance(**block)
@@ -0,0 +1,21 @@
1
+ """Skill-layer models and validators (provenance, schema)."""
2
+
3
+ from core.skills.provenance import (
4
+ FIRST_PARTY,
5
+ METADATA_FIELDS,
6
+ ProvenanceError,
7
+ SkillProvenance,
8
+ declares_provenance,
9
+ parse_provenance,
10
+ provenance_issues,
11
+ )
12
+
13
+ __all__ = [
14
+ "FIRST_PARTY",
15
+ "METADATA_FIELDS",
16
+ "ProvenanceError",
17
+ "SkillProvenance",
18
+ "declares_provenance",
19
+ "parse_provenance",
20
+ "provenance_issues",
21
+ ]
@@ -0,0 +1,157 @@
1
+ """Skill provenance — supply-chain lineage for every SKILL.md.
2
+
3
+ A skill either originates in ArkaOS or it is derived from someone
4
+ else's work. Third-party lineage is a security property, not trivia: a
5
+ skill tree with no origin field cannot be audited after the fact, and
6
+ "which licence is this under?" becomes unanswerable the moment the
7
+ author who ported it moves on.
8
+
9
+ Contract (opt-in, absence means first-party):
10
+
11
+ ---
12
+ name: dev/silent-failure-hunter
13
+ description: ...
14
+ allowed-tools: [Read, Grep, Glob]
15
+ metadata:
16
+ origin: ecc-derived
17
+ source: https://github.com/affaan-m/ecc
18
+ license: MIT
19
+ ---
20
+
21
+ ``origin`` is a lowercase slug (``^[a-z][a-z0-9-]*$``). ``arkaos`` — or
22
+ no metadata block at all — is first-party and needs nothing else. ANY
23
+ other origin MUST declare ``source`` (an https URL) and ``license``.
24
+
25
+ **Fail closed.** A block that is present but unusable — unparseable
26
+ YAML, a tab-indented body, ``metadata`` bound to a scalar, a misspelt
27
+ key (``metadatas:``, ``orgin:``) — is an ERROR, never a silent fall
28
+ back to first-party. Laundering a derived skill into ``arkaos`` by
29
+ typo is the one failure this module exists to prevent.
30
+
31
+ No parser can see the last vector: a port that simply never writes the
32
+ block reads as first-party, and a typo net will always have a hole
33
+ (``metdata:``, ``provenance:``, ``metadata: {}``). Omission is closed
34
+ somewhere else — ``config/skills-provenance.yaml`` classifies every
35
+ skill in the tree as baseline, derived, or self-declaring, and
36
+ ``tests/python/test_skill_provenance.py`` fails CI on any skill that is
37
+ none of the three. A new skill must therefore say what it is. This
38
+ module cannot enforce that and does not claim to.
39
+
40
+ Consumed by ``scripts/skill_validator.py`` (per-skill score) and
41
+ ``scripts/marketplace_gen.py`` (``knowledge/skills-manifest.json``,
42
+ which carries the full origin/source/licence triple, not just origin).
43
+
44
+ The artifact-agnostic core — the model, the constants, the block
45
+ validator — lives in ``core/provenance.py`` and is shared with agent
46
+ provenance (``core/agents/provenance.py``). This module is only the
47
+ SKILL.md-frontmatter reader. ``SkillProvenance`` is re-exported as an
48
+ alias of the generic ``Provenance`` so existing imports keep working.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import re
54
+
55
+ import yaml
56
+ from pydantic import ValidationError
57
+
58
+ from core.provenance import (
59
+ FIRST_PARTY,
60
+ METADATA_FIELDS,
61
+ Provenance,
62
+ ProvenanceError,
63
+ provenance_from_block,
64
+ )
65
+
66
+ # Back-compat alias: a skill's provenance is just Provenance.
67
+ SkillProvenance = Provenance
68
+
69
+ __all__ = [
70
+ "FIRST_PARTY",
71
+ "METADATA_FIELDS",
72
+ "Provenance",
73
+ "ProvenanceError",
74
+ "SkillProvenance",
75
+ "declares_provenance",
76
+ "parse_provenance",
77
+ "provenance_issues",
78
+ ]
79
+
80
+ _FRONTMATTER = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL)
81
+ # Near-misses for `metadata` — a typo must not read as "no block".
82
+ _METADATA_TYPO = re.compile(r"^meta[-_ ]?datas?$", re.IGNORECASE)
83
+
84
+
85
+ def _frontmatter(content: str) -> dict:
86
+ """The YAML frontmatter mapping. Raises when present but broken."""
87
+ match = _FRONTMATTER.match(content)
88
+ if not match:
89
+ return {}
90
+ try:
91
+ data = yaml.safe_load(match.group(1))
92
+ except yaml.YAMLError as exc:
93
+ detail = str(exc).replace("\n", " ")[:120]
94
+ raise ProvenanceError(
95
+ f"frontmatter YAML does not parse: {detail}"
96
+ ) from exc
97
+ if data is None:
98
+ return {}
99
+ if not isinstance(data, dict):
100
+ raise ProvenanceError("frontmatter is not a mapping")
101
+ return data
102
+
103
+
104
+ def _metadata_block(frontmatter: dict) -> dict | None:
105
+ """The `metadata` mapping, or None when genuinely absent."""
106
+ if "metadata" in frontmatter:
107
+ block = frontmatter["metadata"]
108
+ if not isinstance(block, dict):
109
+ raise ProvenanceError(
110
+ "metadata must be a mapping of origin/source/license"
111
+ )
112
+ return block
113
+ typos = [
114
+ key for key in frontmatter
115
+ if isinstance(key, str) and _METADATA_TYPO.match(key)
116
+ ]
117
+ if typos:
118
+ raise ProvenanceError(
119
+ f"key {typos[0]!r} is not read — did you mean 'metadata'?"
120
+ )
121
+ return None
122
+
123
+
124
+ def parse_provenance(content: str) -> SkillProvenance:
125
+ """Read a SKILL.md body. Absent metadata means first-party.
126
+
127
+ Raises ``ProvenanceError``/``ValidationError`` when the block is
128
+ present but incoherent — callers that must not raise use
129
+ ``provenance_issues`` instead.
130
+ """
131
+ return provenance_from_block(_metadata_block(_frontmatter(content)))
132
+
133
+
134
+ def declares_provenance(content: str) -> bool:
135
+ """Did the author WRITE a block, or is this the default?
136
+
137
+ ``parse_provenance`` collapses "declared arkaos" and "said nothing"
138
+ into the same value — correct for reading, useless for the one
139
+ question the classification control has to answer.
140
+ """
141
+ try:
142
+ return _metadata_block(_frontmatter(content)) is not None
143
+ except ProvenanceError:
144
+ return True # broken but attempted; provenance_issues owns it
145
+
146
+
147
+ def provenance_issues(content: str) -> list[str]:
148
+ """Non-raising validation. Empty list means the skill is clean."""
149
+ try:
150
+ parse_provenance(content)
151
+ except ProvenanceError as exc:
152
+ return [str(exc)]
153
+ except ValidationError as exc:
154
+ return [
155
+ err["msg"].removeprefix("Value error, ") for err in exc.errors()
156
+ ]
157
+ return []
@@ -0,0 +1,177 @@
1
+ """Config-protection gate — the agent fixes the code, not the linter.
2
+
3
+ An agent that hits a lint or type error has two ways out: fix the code,
4
+ or edit the config so the check stops firing. The second is faster and
5
+ almost always wrong — it silences the signal for the whole team, not
6
+ just this change. The ECC teardown found a hook for exactly this
7
+ (`config-protection`), and it is the cheapest high-value control in the
8
+ set: refuse edits to linter/formatter/type-checker configs, and the
9
+ agent is left with the honest path.
10
+
11
+ This is a PreToolUse gate. It denies a Write/Edit to a protected config
12
+ file and tells the agent to fix the underlying code instead. Two escape
13
+ hatches, because the rule is a heuristic and the operator is in charge:
14
+
15
+ - ``ARKA_BYPASS_CONFIG_GUARD=1`` in the environment — a deliberate,
16
+ auditable override for the sessions where editing the config IS the
17
+ task (bumping a rule set, adopting a new formatter).
18
+ - an explicit instruction in a recent USER message that names the config
19
+ file — if the operator asked for it, the agent is not sneaking around a
20
+ check, it is doing what it was told.
21
+
22
+ The override reads the operator's messages, never the agent's. Reading
23
+ the agent's own turns would invert the control: the guarded actor could
24
+ authorise its own edit by naming the file, while the operator's real
25
+ request went unseen. The user-message source is the whole point.
26
+
27
+ Failure directions, chosen deliberately:
28
+ - The GATE CHAIN degrades open on infrastructure failure (missing module,
29
+ import error) — a broken guard must not block all edits.
30
+ - The OVERRIDE fails closed: when the operator's intent cannot be read
31
+ (no transcript, unreadable), the edit is treated as unauthorised and
32
+ denied in hard mode. The env bypass is the escape hatch that never
33
+ depends on the transcript, so a locked-out operator is never stuck.
34
+
35
+ Feature-flagged off by default via ``hooks.configGuard`` (off | warn |
36
+ hard) until telemetry says promote.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ import os
43
+ import re
44
+ from dataclasses import dataclass
45
+ from pathlib import Path
46
+
47
+ CONFIG_PATH = Path.home() / ".arkaos" / "config.json"
48
+
49
+ # Config files whose whole purpose is to define a quality bar. Matched on
50
+ # the basename, case-insensitively. A change here weakens the check for
51
+ # everyone, so the agent must not reach for it to get unblocked.
52
+ _PROTECTED_BASENAMES = frozenset({
53
+ # JS/TS linters & formatters
54
+ ".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json",
55
+ ".eslintrc.yml", ".eslintrc.yaml", "eslint.config.js",
56
+ "eslint.config.mjs", "eslint.config.cjs", ".prettierrc",
57
+ ".prettierrc.json", ".prettierrc.js", ".prettierrc.yml",
58
+ ".prettierrc.yaml", "prettier.config.js", "biome.json", "biome.jsonc",
59
+ ".editorconfig",
60
+ # Python
61
+ "ruff.toml", ".ruff.toml", ".flake8", ".pylintrc", "mypy.ini",
62
+ ".mypy.ini", ".pre-commit-config.yaml",
63
+ # PHP / Ruby / Go / Rust
64
+ ".php-cs-fixer.php", ".php-cs-fixer.dist.php", "phpstan.neon",
65
+ "phpstan.neon.dist", "psalm.xml", ".rubocop.yml", ".golangci.yml",
66
+ ".golangci.yaml", "rustfmt.toml", ".rustfmt.toml", "clippy.toml",
67
+ })
68
+ # Files that CARRY tool config among other things — protect only the
69
+ # lint/format/type sections conceptually, but at the file level we can
70
+ # only warn, not know. These are NOT hard-denied (they hold real project
71
+ # config too); they are left to the operator. Documented, not enforced:
72
+ # pyproject.toml, package.json, setup.cfg, tox.ini
73
+ # The basename set above is deliberately the "config IS the check" tier.
74
+
75
+ _ENV_BYPASS = "ARKA_BYPASS_CONFIG_GUARD"
76
+
77
+
78
+ def mode() -> str:
79
+ """Resolve ``hooks.configGuard`` to 'off' | 'warn' | 'hard'.
80
+
81
+ Default 'warn': the gate observes and nudges but never blocks, so it
82
+ ships dark and earns 'hard' on telemetry (the frontend-gate and
83
+ specialist-gate rollout pattern). Unreadable config degrades to
84
+ 'warn', never to 'hard' — a guard must not start blocking because a
85
+ file failed to parse.
86
+ """
87
+ if not CONFIG_PATH.exists():
88
+ return "warn"
89
+ try:
90
+ data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
91
+ except (json.JSONDecodeError, OSError):
92
+ return "warn"
93
+ raw = data.get("hooks", {}).get("configGuard", "warn")
94
+ if raw in (False, "off", "false"):
95
+ return "off"
96
+ if raw in (True, "hard", "true"):
97
+ return "hard"
98
+ return "warn"
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class ConfigGuardDecision:
103
+ """Allow or deny, with the reason on record."""
104
+
105
+ allow: bool
106
+ reason: str = ""
107
+ file_path: str = ""
108
+
109
+ def to_stderr_message(self) -> str:
110
+ if self.allow:
111
+ return ""
112
+ return (
113
+ f"[arka:config-guard] Refusing to edit {self.file_path}. That "
114
+ f"file defines the quality bar; editing it to clear a lint, "
115
+ f"type, or format error silences the check for the whole team "
116
+ f"instead of fixing the code. Fix the underlying code.\n"
117
+ f"To lift this, the OPERATOR must name {self.file_path} in a "
118
+ f"message (an agent cannot authorise its own config edit), or "
119
+ f"set {_ENV_BYPASS}=1 in the environment."
120
+ )
121
+
122
+
123
+ def is_protected_config(file_path: str) -> bool:
124
+ """Whether a path is a config file whose edit weakens a check."""
125
+ if not file_path:
126
+ return False
127
+ return Path(file_path).name.lower() in _PROTECTED_BASENAMES
128
+
129
+
130
+ def _operator_named_file(
131
+ user_messages: list[str] | None, file_path: str
132
+ ) -> bool:
133
+ """Did the OPERATOR name this config file in a recent message?
134
+
135
+ The whole basename must appear as its own token. The trailing guard
136
+ blocks a sibling file — ``ruff.toml.bak``, ``ruff.toml~``,
137
+ ``ruff.toml-old`` must not authorise an edit to ``ruff.toml`` — while
138
+ still allowing a sentence-ending period (``edit ruff.toml.`` should
139
+ match): the negative lookahead fires on a dot/tilde/hyphen only when
140
+ it continues into a filename, not on end punctuation. Case-insensitive
141
+ to match ``is_protected_config``, which lowercases the basename.
142
+ """
143
+ if not user_messages or not file_path:
144
+ return False
145
+ name = re.escape(Path(file_path).name)
146
+ pattern = re.compile(
147
+ rf"(?<![\w.]){name}(?![\w])(?!\.\w)(?![~-])", re.IGNORECASE
148
+ )
149
+ return any(
150
+ pattern.search(m) for m in user_messages if isinstance(m, str)
151
+ )
152
+
153
+
154
+ def evaluate(
155
+ file_path: str,
156
+ user_messages: list[str] | None = None,
157
+ ) -> ConfigGuardDecision:
158
+ """Decide whether to allow an edit to a protected config file.
159
+
160
+ Allows everything that is not a protected config; denies a protected
161
+ config edit unless the env bypass is set or the OPERATOR named the
162
+ file in a recent message. ``user_messages`` must be operator turns —
163
+ passing the agent's own messages inverts the control.
164
+ """
165
+ if not is_protected_config(file_path):
166
+ return ConfigGuardDecision(allow=True)
167
+ if os.environ.get(_ENV_BYPASS) == "1":
168
+ return ConfigGuardDecision(
169
+ allow=True, reason="env-bypass", file_path=file_path
170
+ )
171
+ if _operator_named_file(user_messages, file_path):
172
+ return ConfigGuardDecision(
173
+ allow=True, reason="operator-named-file", file_path=file_path
174
+ )
175
+ return ConfigGuardDecision(
176
+ allow=False, reason="protected-config", file_path=file_path
177
+ )