arkaos 4.17.0 → 4.19.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.
@@ -7,12 +7,10 @@ Three core models:
7
7
  """
8
8
 
9
9
  import uuid
10
- from datetime import datetime, timezone
11
- from typing import Literal
10
+ from datetime import UTC, datetime
12
11
 
13
12
  from pydantic import BaseModel, Field, field_validator
14
13
 
15
-
16
14
  # --- Allowed categories and statuses ---
17
15
 
18
16
  RAW_CAPTURE_CATEGORIES = ("decision", "solution", "pattern", "error", "config")
@@ -35,7 +33,7 @@ ACTIONABLE_INSIGHT_STATUSES = ("pending", "presented", "accepted", "dismissed")
35
33
 
36
34
 
37
35
  def _utc_now() -> datetime:
38
- return datetime.now(timezone.utc)
36
+ return datetime.now(UTC)
39
37
 
40
38
 
41
39
  def _new_uuid() -> str:
@@ -97,8 +95,26 @@ class KnowledgeEntry(BaseModel):
97
95
  return max(0.0, min(1.0, v))
98
96
 
99
97
 
98
+ # Instinct confidence lives in a band, never at the poles. 0.3 is a weak
99
+ # hypothesis worth keeping; 0.9 is strong but never certainty (an agent
100
+ # that is "certain" from a handful of observations is the failure the
101
+ # band prevents). Follows an established continuous-learning instinct model.
102
+ INSTINCT_CONFIDENCE_MIN = 0.3
103
+ INSTINCT_CONFIDENCE_MAX = 0.9
104
+ INSTINCT_SCOPES = ("project", "global")
105
+
106
+
100
107
  class ActionableInsight(BaseModel):
101
- """Actionable insight — generated by agents for proactive presentation."""
108
+ """Actionable insight — generated by agents for proactive presentation.
109
+
110
+ Carries the instinct fields (confidence, scope,
111
+ evidence_count) so an insight can strengthen on corroboration, weaken
112
+ on contradiction, and be promoted from project to global once the
113
+ same signal is seen across enough projects with high enough mean
114
+ confidence (see ``InsightStore.promotable``). All three default to
115
+ backwards-compatible values, so an insight written before this schema
116
+ reads as a fresh, project-scoped hypothesis.
117
+ """
102
118
 
103
119
  id: str = Field(default_factory=_new_uuid)
104
120
  project: str
@@ -113,6 +129,30 @@ class ActionableInsight(BaseModel):
113
129
  status: str = "pending"
114
130
  presented_at: datetime | None = None
115
131
 
132
+ confidence: float = 0.5
133
+ scope: str = "project"
134
+ evidence_count: int = 1
135
+
136
+ @field_validator("confidence")
137
+ @classmethod
138
+ def clamp_confidence(cls, v: float) -> float:
139
+ """Confidence is bounded to the instinct band, never the poles."""
140
+ return max(INSTINCT_CONFIDENCE_MIN, min(INSTINCT_CONFIDENCE_MAX, v))
141
+
142
+ @field_validator("scope")
143
+ @classmethod
144
+ def validate_scope(cls, v: str) -> str:
145
+ if v not in INSTINCT_SCOPES:
146
+ raise ValueError(
147
+ f"Invalid scope '{v}'. Must be one of: {INSTINCT_SCOPES}"
148
+ )
149
+ return v
150
+
151
+ @field_validator("evidence_count")
152
+ @classmethod
153
+ def evidence_count_is_positive(cls, v: int) -> int:
154
+ return max(1, v)
155
+
116
156
  @field_validator("category")
117
157
  @classmethod
118
158
  def validate_category(cls, v: str) -> str:
@@ -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)
@@ -9,18 +9,18 @@ author who ported it moves on.
9
9
  Contract (opt-in, absence means first-party):
10
10
 
11
11
  ---
12
- name: dev/silent-failure-hunter
12
+ name: dev/example-skill
13
13
  description: ...
14
14
  allowed-tools: [Read, Grep, Glob]
15
15
  metadata:
16
- origin: ecc-derived
17
- source: https://github.com/affaan-m/ecc
16
+ origin: vendor-derived
17
+ source: https://example.com/upstream
18
18
  license: MIT
19
19
  ---
20
20
 
21
21
  ``origin`` is a lowercase slug (``^[a-z][a-z0-9-]*$``). ``arkaos`` — or
22
22
  no metadata block at all — is first-party and needs nothing else. ANY
23
- other origin MUST declare ``source`` (an http(s) URL) and ``license``.
23
+ other origin MUST declare ``source`` (an https URL) and ``license``.
24
24
 
25
25
  **Fail closed.** A block that is present but unusable — unparseable
26
26
  YAML, a tab-indented body, ``metadata`` bound to a scalar, a misspelt
@@ -40,6 +40,12 @@ module cannot enforce that and does not claim to.
40
40
  Consumed by ``scripts/skill_validator.py`` (per-skill score) and
41
41
  ``scripts/marketplace_gen.py`` (``knowledge/skills-manifest.json``,
42
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.
43
49
  """
44
50
 
45
51
  from __future__ import annotations
@@ -47,70 +53,35 @@ from __future__ import annotations
47
53
  import re
48
54
 
49
55
  import yaml
50
- from pydantic import BaseModel, Field, ValidationError, model_validator
51
-
52
- FIRST_PARTY = "arkaos"
53
- METADATA_FIELDS = ("origin", "source", "license")
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
+ ]
54
79
 
55
80
  _FRONTMATTER = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL)
56
- _ORIGIN_SLUG = re.compile(r"^[a-z][a-z0-9-]*$")
57
- # https only: a licence trail that can be MITM'd is not a trail.
58
- _URL = re.compile(r"^https://\S+$")
59
81
  # Near-misses for `metadata` — a typo must not read as "no block".
60
82
  _METADATA_TYPO = re.compile(r"^meta[-_ ]?datas?$", re.IGNORECASE)
61
83
 
62
84
 
63
- class ProvenanceError(ValueError):
64
- """The provenance block is present but cannot be trusted."""
65
-
66
-
67
- class SkillProvenance(BaseModel):
68
- """Where a skill came from, and under what licence."""
69
-
70
- origin: str = Field(default=FIRST_PARTY)
71
- source: str | None = None
72
- license: str | None = None
73
-
74
- @model_validator(mode="after")
75
- def _derived_needs_a_trail(self) -> SkillProvenance:
76
- if not _ORIGIN_SLUG.match(self.origin):
77
- raise ValueError(
78
- f"origin {self.origin!r} is not a lowercase slug "
79
- f"(^[a-z][a-z0-9-]*$)"
80
- )
81
- if self.origin == FIRST_PARTY:
82
- return self._first_party_carries_no_trail()
83
- missing = [f for f in ("source", "license") if not getattr(self, f)]
84
- if missing:
85
- raise ValueError(
86
- f"origin {self.origin!r} is third-party — "
87
- f"{', '.join(missing)} required"
88
- )
89
- if not _URL.match(self.source or ""):
90
- raise ValueError(f"source {self.source!r} is not an https URL")
91
- return self
92
-
93
- def _first_party_carries_no_trail(self) -> SkillProvenance:
94
- """`arkaos` plus a source/licence is an unresolved contradiction.
95
-
96
- Either the skill is ours and there is nothing to attribute, or
97
- it is not and the origin is wrong. Silence would let a bad port
98
- keep its upstream URL while claiming to be first-party.
99
- """
100
- declared = [f for f in ("source", "license") if getattr(self, f)]
101
- if declared:
102
- raise ValueError(
103
- f"origin {FIRST_PARTY!r} is first-party but declares "
104
- f"{', '.join(declared)} — set a third-party origin or "
105
- f"drop the field"
106
- )
107
- return self
108
-
109
- @property
110
- def is_first_party(self) -> bool:
111
- return self.origin == FIRST_PARTY
112
-
113
-
114
85
  def _frontmatter(content: str) -> dict:
115
86
  """The YAML frontmatter mapping. Raises when present but broken."""
116
87
  match = _FRONTMATTER.match(content)
@@ -157,16 +128,7 @@ def parse_provenance(content: str) -> SkillProvenance:
157
128
  present but incoherent — callers that must not raise use
158
129
  ``provenance_issues`` instead.
159
130
  """
160
- block = _metadata_block(_frontmatter(content))
161
- if block is None:
162
- return SkillProvenance()
163
- unknown = sorted(str(k) for k in block if k not in METADATA_FIELDS)
164
- if unknown:
165
- raise ProvenanceError(
166
- f"unknown metadata keys: {', '.join(unknown)} "
167
- f"(expected {', '.join(METADATA_FIELDS)})"
168
- )
169
- return SkillProvenance(**block)
131
+ return provenance_from_block(_metadata_block(_frontmatter(content)))
170
132
 
171
133
 
172
134
  def declares_provenance(content: str) -> bool:
@@ -0,0 +1,176 @@
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. This gate is the cheapest high-value control in the
7
+ set: refuse edits to linter/formatter/type-checker configs, and the
8
+ agent is left with the honest path.
9
+
10
+ This is a PreToolUse gate. It denies a Write/Edit to a protected config
11
+ file and tells the agent to fix the underlying code instead. Two escape
12
+ hatches, because the rule is a heuristic and the operator is in charge:
13
+
14
+ - ``ARKA_BYPASS_CONFIG_GUARD=1`` in the environment — a deliberate,
15
+ auditable override for the sessions where editing the config IS the
16
+ task (bumping a rule set, adopting a new formatter).
17
+ - an explicit instruction in a recent USER message that names the config
18
+ file — if the operator asked for it, the agent is not sneaking around a
19
+ check, it is doing what it was told.
20
+
21
+ The override reads the operator's messages, never the agent's. Reading
22
+ the agent's own turns would invert the control: the guarded actor could
23
+ authorise its own edit by naming the file, while the operator's real
24
+ request went unseen. The user-message source is the whole point.
25
+
26
+ Failure directions, chosen deliberately:
27
+ - The GATE CHAIN degrades open on infrastructure failure (missing module,
28
+ import error) — a broken guard must not block all edits.
29
+ - The OVERRIDE fails closed: when the operator's intent cannot be read
30
+ (no transcript, unreadable), the edit is treated as unauthorised and
31
+ denied in hard mode. The env bypass is the escape hatch that never
32
+ depends on the transcript, so a locked-out operator is never stuck.
33
+
34
+ Feature-flagged off by default via ``hooks.configGuard`` (off | warn |
35
+ hard) until telemetry says promote.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import os
42
+ import re
43
+ from dataclasses import dataclass
44
+ from pathlib import Path
45
+
46
+ CONFIG_PATH = Path.home() / ".arkaos" / "config.json"
47
+
48
+ # Config files whose whole purpose is to define a quality bar. Matched on
49
+ # the basename, case-insensitively. A change here weakens the check for
50
+ # everyone, so the agent must not reach for it to get unblocked.
51
+ _PROTECTED_BASENAMES = frozenset({
52
+ # JS/TS linters & formatters
53
+ ".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json",
54
+ ".eslintrc.yml", ".eslintrc.yaml", "eslint.config.js",
55
+ "eslint.config.mjs", "eslint.config.cjs", ".prettierrc",
56
+ ".prettierrc.json", ".prettierrc.js", ".prettierrc.yml",
57
+ ".prettierrc.yaml", "prettier.config.js", "biome.json", "biome.jsonc",
58
+ ".editorconfig",
59
+ # Python
60
+ "ruff.toml", ".ruff.toml", ".flake8", ".pylintrc", "mypy.ini",
61
+ ".mypy.ini", ".pre-commit-config.yaml",
62
+ # PHP / Ruby / Go / Rust
63
+ ".php-cs-fixer.php", ".php-cs-fixer.dist.php", "phpstan.neon",
64
+ "phpstan.neon.dist", "psalm.xml", ".rubocop.yml", ".golangci.yml",
65
+ ".golangci.yaml", "rustfmt.toml", ".rustfmt.toml", "clippy.toml",
66
+ })
67
+ # Files that CARRY tool config among other things — protect only the
68
+ # lint/format/type sections conceptually, but at the file level we can
69
+ # only warn, not know. These are NOT hard-denied (they hold real project
70
+ # config too); they are left to the operator. Documented, not enforced:
71
+ # pyproject.toml, package.json, setup.cfg, tox.ini
72
+ # The basename set above is deliberately the "config IS the check" tier.
73
+
74
+ _ENV_BYPASS = "ARKA_BYPASS_CONFIG_GUARD"
75
+
76
+
77
+ def mode() -> str:
78
+ """Resolve ``hooks.configGuard`` to 'off' | 'warn' | 'hard'.
79
+
80
+ Default 'warn': the gate observes and nudges but never blocks, so it
81
+ ships dark and earns 'hard' on telemetry (the frontend-gate and
82
+ specialist-gate rollout pattern). Unreadable config degrades to
83
+ 'warn', never to 'hard' — a guard must not start blocking because a
84
+ file failed to parse.
85
+ """
86
+ if not CONFIG_PATH.exists():
87
+ return "warn"
88
+ try:
89
+ data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
90
+ except (json.JSONDecodeError, OSError):
91
+ return "warn"
92
+ raw = data.get("hooks", {}).get("configGuard", "warn")
93
+ if raw in (False, "off", "false"):
94
+ return "off"
95
+ if raw in (True, "hard", "true"):
96
+ return "hard"
97
+ return "warn"
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class ConfigGuardDecision:
102
+ """Allow or deny, with the reason on record."""
103
+
104
+ allow: bool
105
+ reason: str = ""
106
+ file_path: str = ""
107
+
108
+ def to_stderr_message(self) -> str:
109
+ if self.allow:
110
+ return ""
111
+ return (
112
+ f"[arka:config-guard] Refusing to edit {self.file_path}. That "
113
+ f"file defines the quality bar; editing it to clear a lint, "
114
+ f"type, or format error silences the check for the whole team "
115
+ f"instead of fixing the code. Fix the underlying code.\n"
116
+ f"To lift this, the OPERATOR must name {self.file_path} in a "
117
+ f"message (an agent cannot authorise its own config edit), or "
118
+ f"set {_ENV_BYPASS}=1 in the environment."
119
+ )
120
+
121
+
122
+ def is_protected_config(file_path: str) -> bool:
123
+ """Whether a path is a config file whose edit weakens a check."""
124
+ if not file_path:
125
+ return False
126
+ return Path(file_path).name.lower() in _PROTECTED_BASENAMES
127
+
128
+
129
+ def _operator_named_file(
130
+ user_messages: list[str] | None, file_path: str
131
+ ) -> bool:
132
+ """Did the OPERATOR name this config file in a recent message?
133
+
134
+ The whole basename must appear as its own token. The trailing guard
135
+ blocks a sibling file — ``ruff.toml.bak``, ``ruff.toml~``,
136
+ ``ruff.toml-old`` must not authorise an edit to ``ruff.toml`` — while
137
+ still allowing a sentence-ending period (``edit ruff.toml.`` should
138
+ match): the negative lookahead fires on a dot/tilde/hyphen only when
139
+ it continues into a filename, not on end punctuation. Case-insensitive
140
+ to match ``is_protected_config``, which lowercases the basename.
141
+ """
142
+ if not user_messages or not file_path:
143
+ return False
144
+ name = re.escape(Path(file_path).name)
145
+ pattern = re.compile(
146
+ rf"(?<![\w.]){name}(?![\w])(?!\.\w)(?![~-])", re.IGNORECASE
147
+ )
148
+ return any(
149
+ pattern.search(m) for m in user_messages if isinstance(m, str)
150
+ )
151
+
152
+
153
+ def evaluate(
154
+ file_path: str,
155
+ user_messages: list[str] | None = None,
156
+ ) -> ConfigGuardDecision:
157
+ """Decide whether to allow an edit to a protected config file.
158
+
159
+ Allows everything that is not a protected config; denies a protected
160
+ config edit unless the env bypass is set or the OPERATOR named the
161
+ file in a recent message. ``user_messages`` must be operator turns —
162
+ passing the agent's own messages inverts the control.
163
+ """
164
+ if not is_protected_config(file_path):
165
+ return ConfigGuardDecision(allow=True)
166
+ if os.environ.get(_ENV_BYPASS) == "1":
167
+ return ConfigGuardDecision(
168
+ allow=True, reason="env-bypass", file_path=file_path
169
+ )
170
+ if _operator_named_file(user_messages, file_path):
171
+ return ConfigGuardDecision(
172
+ allow=True, reason="operator-named-file", file_path=file_path
173
+ )
174
+ return ConfigGuardDecision(
175
+ allow=False, reason="protected-config", file_path=file_path
176
+ )
@@ -72,3 +72,49 @@ def split_from_path(transcript_path: str) -> ScopeSplit:
72
72
  except OSError:
73
73
  return ScopeSplit()
74
74
  return split_by_scope(raw)
75
+
76
+
77
+ def recent_user_messages(raw_text: str, limit: int = 6) -> list[str]:
78
+ """The most recent USER message texts, newest last.
79
+
80
+ ``split_by_scope`` collects assistant records only. A gate that must
81
+ know what the OPERATOR asked — not what the agent said — needs the
82
+ other role, and reading the wrong one inverts the gate: the guarded
83
+ agent could authorise its own edit while the operator's real request
84
+ is never seen. Sidechain (subagent) user turns are excluded — a
85
+ dispatched agent's prompt is not the operator speaking.
86
+ """
87
+ found: list[str] = []
88
+ for line in raw_text.splitlines():
89
+ if not line.strip():
90
+ continue
91
+ try:
92
+ record = json.loads(line)
93
+ except json.JSONDecodeError:
94
+ continue
95
+ if not isinstance(record, dict):
96
+ continue
97
+ message = record.get("message")
98
+ message = message if isinstance(message, dict) else {}
99
+ role = record.get("role") or message.get("role")
100
+ if role != "user" or record.get("isSidechain", False):
101
+ continue
102
+ content = record.get("content")
103
+ if content is None:
104
+ content = message.get("content")
105
+ text = _extract_text(content)
106
+ if text:
107
+ found.append(text)
108
+ return found[-limit:]
109
+
110
+
111
+ def user_messages_from_path(transcript_path: str, limit: int = 6) -> list[str]:
112
+ """Recent operator messages from a transcript file. Never raises."""
113
+ path = Path(transcript_path) if transcript_path else None
114
+ if path is None or not path.exists():
115
+ return []
116
+ try:
117
+ raw = path.read_text(encoding="utf-8", errors="replace")
118
+ except OSError:
119
+ return []
120
+ return recent_user_messages(raw, limit)