arkaos 4.17.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.
- package/README.md +1 -1
- package/VERSION +1 -1
- package/arka/SKILL.md +1 -1
- package/config/agents-provenance.yaml +126 -0
- package/config/skills-provenance.yaml +20 -1
- package/core/agents/provenance.py +117 -0
- package/core/agents/registry_gen.py +75 -57
- package/core/cognition/insights/store.py +114 -7
- package/core/cognition/memory/schemas.py +45 -5
- package/core/hooks/pre_tool_use.py +59 -0
- package/core/provenance.py +108 -0
- package/core/skills/provenance.py +31 -69
- package/core/workflow/config_guard.py +177 -0
- package/core/workflow/transcript_scope.py +46 -0
- package/departments/dev/skills/click-path-audit/SKILL.md +76 -0
- package/departments/dev/skills/pr-test-analyzer/SKILL.md +79 -0
- package/departments/dev/skills/silent-failure-hunter/SKILL.md +72 -0
- package/departments/dev/skills/type-design-analyzer/SKILL.md +73 -0
- package/knowledge/agents-registry-v2.json +259 -1
- package/knowledge/skills-manifest.json +61 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
|
@@ -7,12 +7,10 @@ Three core models:
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import uuid
|
|
10
|
-
from datetime import
|
|
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(
|
|
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). Mirrors the ECC 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 ECC-style 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)
|
|
@@ -20,7 +20,7 @@ Contract (opt-in, absence means first-party):
|
|
|
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
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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,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
|
+
)
|
|
@@ -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)
|