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.
- 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 +357 -0
- 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/governance/harness_scanner.py +776 -0
- package/core/governance/harness_scanner_cli.py +131 -0
- package/core/hooks/pre_tool_use.py +59 -0
- package/core/provenance.py +108 -0
- package/core/skills/__init__.py +21 -0
- package/core/skills/provenance.py +157 -0
- 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/installer/cli.js +24 -0
- package/installer/doctor.js +46 -0
- package/knowledge/agents-registry-v2.json +259 -1
- package/knowledge/skills-manifest.json +1005 -237
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/marketplace_gen.py +57 -1
- package/scripts/skill_validator.py +133 -62
|
@@ -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
|
+
)
|