arkaos 4.46.0 → 4.48.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/THE-ARKAOS-GUIDE.md +1 -1
- package/VERSION +1 -1
- package/core/egress/policy.py +84 -32
- package/core/harness/cli.py +143 -0
- package/core/harness/drift.py +160 -9
- package/core/harness/manager.py +577 -0
- package/core/harness/manifest.py +5 -1
- package/core/kb/__init__.py +9 -0
- package/core/kb/nlm_client.py +883 -0
- package/harness/codex/AGENTS.md +1 -1
- package/harness/copilot/copilot-instructions.md +1 -1
- package/harness/cursor/rules/arkaos.mdc +2 -2
- package/harness/gemini/GEMINI.md +1 -1
- package/harness/opencode/AGENTS.md +1 -1
- package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
- package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
- package/harness/opencode/agents/arka-cfo-helena.md +1 -1
- package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
- package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
- package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
- package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
- package/harness/opencode/agents/arka-coo-sofia.md +1 -1
- package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
- package/harness/opencode/agents/arka-cqo-marta.md +1 -1
- package/harness/opencode/agents/arka-cto-marco.md +1 -1
- package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
- package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
- package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
- package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
- package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
- package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
- package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
- package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
- package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
- package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
- package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
- package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
- package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
- package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
- package/harness/zed/.rules +1 -1
- package/installer/cli.js +27 -0
- package/knowledge/skills-manifest.json +1 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/THE-ARKAOS-GUIDE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# The ArkaOS Guide
|
|
2
2
|
|
|
3
|
-
> v4.
|
|
3
|
+
> v4.48.0 — 89 agents, 17 departments, 332 skills, 297 commands, 19 ADRs.
|
|
4
4
|
> One file, everything you need to start. Generated by `scripts/guide_gen.py` — never hand-edited.
|
|
5
5
|
|
|
6
6
|
## What it is
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.
|
|
1
|
+
4.48.0
|
package/core/egress/policy.py
CHANGED
|
@@ -28,6 +28,7 @@ import re
|
|
|
28
28
|
from dataclasses import dataclass, field
|
|
29
29
|
from datetime import datetime
|
|
30
30
|
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
31
32
|
|
|
32
33
|
from core.egress import allowlist, audit, redact
|
|
33
34
|
from core.governance.harness_scanner import secret_labels
|
|
@@ -42,7 +43,7 @@ class Finding:
|
|
|
42
43
|
# # | audit-unavailable | payload-not-text | guard-failure
|
|
43
44
|
token: str
|
|
44
45
|
|
|
45
|
-
def to_audit(self, salt: bytes = b"") -> dict:
|
|
46
|
+
def to_audit(self, salt: bytes = b"") -> dict[str, Any]:
|
|
46
47
|
return {
|
|
47
48
|
"kind": self.kind,
|
|
48
49
|
"token_sha16": (
|
|
@@ -64,7 +65,7 @@ class EgressDecision:
|
|
|
64
65
|
redacted_sha256: str = ""
|
|
65
66
|
audited: bool = False
|
|
66
67
|
|
|
67
|
-
def to_audit(self, salt: bytes = b"") -> dict:
|
|
68
|
+
def to_audit(self, salt: bytes = b"") -> dict[str, Any]:
|
|
68
69
|
return {
|
|
69
70
|
"allowed": self.allowed,
|
|
70
71
|
"destination": self.destination,
|
|
@@ -99,26 +100,43 @@ def evaluate(
|
|
|
99
100
|
) -> EgressDecision:
|
|
100
101
|
"""Judge one payload against the policy. Never raises.
|
|
101
102
|
|
|
102
|
-
``home`` scopes the home-path check
|
|
103
|
-
audit locations; ``now`` pins allowlist expiry
|
|
103
|
+
``home`` scopes the home-path check, the redaction config and the
|
|
104
|
+
default allowlist / audit locations; ``now`` pins allowlist expiry
|
|
105
|
+
for tests.
|
|
104
106
|
"""
|
|
105
107
|
destination = _safe_str(destination)
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
|
|
108
|
+
# ONCE, before any failure path, so no handler re-runs what
|
|
109
|
+
# failed. Named `digest`: `payload_digest` shadowed the public
|
|
110
|
+
# function here (QG D2 r12, Francisca M2).
|
|
111
|
+
digest = payload_digest(text) if isinstance(text, str) else ""
|
|
109
112
|
try:
|
|
110
113
|
decision = _judge(
|
|
111
114
|
text, destination, config_path, home, allowlist_path, now
|
|
112
115
|
)
|
|
113
116
|
except Exception as exc: # never-raises boundary — deny, not crash
|
|
114
|
-
decision =
|
|
115
|
-
allowed=False, destination=destination,
|
|
116
|
-
payload_sha256=payload_digest,
|
|
117
|
-
findings=[Finding("guard-failure", type(exc).__name__)],
|
|
118
|
-
)
|
|
117
|
+
decision = _guard_failure(destination, digest, exc)
|
|
119
118
|
return _audited(decision, home, audit_path, now)
|
|
120
119
|
|
|
121
120
|
|
|
121
|
+
def _guard_failure(
|
|
122
|
+
destination: str, digest: str, exc: BaseException
|
|
123
|
+
) -> EgressDecision:
|
|
124
|
+
return EgressDecision(
|
|
125
|
+
allowed=False, destination=destination, payload_sha256=digest,
|
|
126
|
+
findings=[Finding("guard-failure", type(exc).__name__)],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def default_redaction_config_path(home: Path | None = None) -> Path:
|
|
131
|
+
"""The identifier list *home* implies.
|
|
132
|
+
|
|
133
|
+
Mirrors ``leak_scanner._DEFAULT_CONFIG_PATH`` for the real home, so
|
|
134
|
+
scoping by ``home`` never changes production behaviour — pinned by
|
|
135
|
+
``test_egress_policy.py`` rather than by this comment.
|
|
136
|
+
"""
|
|
137
|
+
return (home or Path.home()) / ".arkaos" / "redaction-clients.json"
|
|
138
|
+
|
|
139
|
+
|
|
122
140
|
def _safe_str(value: object) -> str:
|
|
123
141
|
try:
|
|
124
142
|
return str(value)
|
|
@@ -150,7 +168,7 @@ def _audited(
|
|
|
150
168
|
return decision
|
|
151
169
|
|
|
152
170
|
|
|
153
|
-
def enforce(text: object, destination: str, **kwargs) -> str:
|
|
171
|
+
def enforce(text: object, destination: str, **kwargs: Any) -> str:
|
|
154
172
|
"""The redacted text cleared to leave, or :class:`EgressDeniedError`."""
|
|
155
173
|
decision = evaluate(text, destination, **kwargs)
|
|
156
174
|
if not decision.allowed or decision.redacted_text is None:
|
|
@@ -167,41 +185,70 @@ def _judge(
|
|
|
167
185
|
now: datetime | None,
|
|
168
186
|
) -> EgressDecision:
|
|
169
187
|
if not isinstance(text, str):
|
|
170
|
-
return
|
|
171
|
-
allowed=False, destination=destination, payload_sha256="",
|
|
172
|
-
findings=[Finding("payload-not-text", type(text).__name__)],
|
|
173
|
-
)
|
|
188
|
+
return _not_text(destination, text)
|
|
174
189
|
decision = EgressDecision(
|
|
175
190
|
allowed=False, destination=destination,
|
|
176
|
-
payload_sha256=
|
|
191
|
+
payload_sha256=payload_digest(text),
|
|
177
192
|
)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
193
|
+
# One scoped path for both layers: scoping only the redaction call
|
|
194
|
+
# left residual_identifiers reading the real machine's list
|
|
195
|
+
# (QG D2 r3, Francisca B1).
|
|
196
|
+
scoped = _scoped_config(config_path, home)
|
|
197
|
+
clean = _redacted(text, scoped)
|
|
198
|
+
if isinstance(clean, Finding):
|
|
199
|
+
decision.findings.append(clean)
|
|
181
200
|
return decision
|
|
182
201
|
_collect_findings(
|
|
183
|
-
decision, clean, destination,
|
|
202
|
+
decision, clean, destination, scoped, home, allowlist_path, now
|
|
184
203
|
)
|
|
185
204
|
if not decision.findings:
|
|
186
205
|
decision.allowed = True
|
|
187
206
|
decision.redacted_text = clean
|
|
188
|
-
decision.redacted_sha256 =
|
|
207
|
+
decision.redacted_sha256 = payload_digest(clean)
|
|
189
208
|
return decision
|
|
190
209
|
|
|
191
210
|
|
|
192
|
-
def
|
|
211
|
+
def _not_text(destination: str, text: object) -> EgressDecision:
|
|
212
|
+
return EgressDecision(
|
|
213
|
+
allowed=False, destination=destination, payload_sha256="",
|
|
214
|
+
findings=[Finding("payload-not-text", type(text).__name__)],
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def payload_digest(text: str) -> str:
|
|
193
219
|
"""Total over any str — surrogatepass, because a payload holding a
|
|
194
220
|
lone surrogate (routine from errors="surrogateescape" decoding or
|
|
195
221
|
json.loads of an escape) must be DIGESTIBLE to be denied with an
|
|
196
|
-
audit line (QG D1 r2 E-B1).
|
|
222
|
+
audit line (QG D1 r2 E-B1).
|
|
223
|
+
|
|
224
|
+
Public because callers outside this package need the same digest
|
|
225
|
+
the audit trail records; reaching for a private symbol made a D1
|
|
226
|
+
rename a silent runtime break downstream (QG D2 r1, Francisca M6).
|
|
227
|
+
"""
|
|
197
228
|
payload = text.encode("utf-8", errors="surrogatepass")
|
|
198
229
|
return hashlib.sha256(payload).hexdigest()
|
|
199
230
|
|
|
200
231
|
|
|
201
|
-
def
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
232
|
+
def _scoped_config(config_path: Path | None, home: Path | None) -> Path | None:
|
|
233
|
+
"""The redaction config *home* implies, unless one was named.
|
|
234
|
+
|
|
235
|
+
``home`` scoped ``allowlist_path`` and ``audit_path`` but NOT the
|
|
236
|
+
redaction config, so a caller passing ``home=`` and forgetting
|
|
237
|
+
``config_path`` judged paths against one home while redacting
|
|
238
|
+
against another (QG D2 r1, Francisca M7).
|
|
239
|
+
|
|
240
|
+
Called from inside ``_judge``, i.e. inside ``evaluate``'s try: the
|
|
241
|
+
same defaulting one frame up sat OUTSIDE it, and a non-Path home
|
|
242
|
+
turned the never-raises boundary into a TypeError — the very shape
|
|
243
|
+
QG D1 r2 F-M5 already closed once.
|
|
244
|
+
"""
|
|
245
|
+
if config_path is not None or home is None:
|
|
246
|
+
return config_path
|
|
247
|
+
return default_redaction_config_path(home)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _redacted(text: str, config_path: Path | None) -> str | Finding:
|
|
251
|
+
"""The clean text, or the finding that denies it.
|
|
205
252
|
|
|
206
253
|
A redaction that CRASHES proves nothing about the payload — same
|
|
207
254
|
posture as a missing config: denied, never allowlistable (QG D1
|
|
@@ -209,11 +256,16 @@ def _redacted(
|
|
|
209
256
|
"""
|
|
210
257
|
try:
|
|
211
258
|
clean, _counts = redact.redact(text, config_path)
|
|
212
|
-
|
|
259
|
+
# Annotated: redact() is untyped, so `clean` arrives as Any and
|
|
260
|
+
# the union return silently degrades to Any (mypy no-any-return,
|
|
261
|
+
# surfaced only with --follow-imports=skip —
|
|
262
|
+
# QG D2 r3, Francisca M3).
|
|
263
|
+
clean_text: str = clean
|
|
264
|
+
return clean_text
|
|
213
265
|
except redact.SanitizerConfigMissing:
|
|
214
|
-
return
|
|
266
|
+
return Finding("redaction-config-missing", "")
|
|
215
267
|
except Exception as exc:
|
|
216
|
-
return
|
|
268
|
+
return Finding("redaction-failed", type(exc).__name__)
|
|
217
269
|
|
|
218
270
|
|
|
219
271
|
def _collect_findings(
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""CLI for the harness manager — ``npx arkaos harness <verb>``.
|
|
2
|
+
|
|
3
|
+
Verbs: ``status`` (read-only), ``assert`` (apply policies), ``restore``
|
|
4
|
+
(assert + re-seed adopted surfaces), ``harden`` (assert + scanner
|
|
5
|
+
grade, nonzero exit below B), ``flags`` (read, or set with an explicit
|
|
6
|
+
``--set name=value``). Every verb but ``flags`` takes ``--json``.
|
|
7
|
+
Exit codes are the contract the Node wrapper propagates verbatim.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from core.harness.manager import ClaudeConfigManager
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv: list[str] | None = None) -> int:
|
|
20
|
+
args = _parser().parse_args(argv)
|
|
21
|
+
handler = {
|
|
22
|
+
"status": _status,
|
|
23
|
+
"assert": _assert,
|
|
24
|
+
"restore": _restore,
|
|
25
|
+
"harden": _harden,
|
|
26
|
+
"flags": _flags,
|
|
27
|
+
}[args.verb]
|
|
28
|
+
try:
|
|
29
|
+
return handler(ClaudeConfigManager(), args)
|
|
30
|
+
except Exception as exc: # operators get a message, never a traceback
|
|
31
|
+
print(f"harness {args.verb} failed: {exc}", file=sys.stderr)
|
|
32
|
+
return 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parser() -> argparse.ArgumentParser:
|
|
36
|
+
parser = argparse.ArgumentParser(
|
|
37
|
+
prog="python -m core.harness.cli",
|
|
38
|
+
description="Assert and report ArkaOS ownership of the harness.",
|
|
39
|
+
)
|
|
40
|
+
sub = parser.add_subparsers(dest="verb", required=True)
|
|
41
|
+
for verb in ("status", "assert", "restore", "harden"):
|
|
42
|
+
sub.add_parser(verb).add_argument(
|
|
43
|
+
"--json", action="store_true", dest="as_json"
|
|
44
|
+
)
|
|
45
|
+
flags = sub.add_parser("flags")
|
|
46
|
+
flags.add_argument(
|
|
47
|
+
"--set", dest="assignment", default=None,
|
|
48
|
+
help=(
|
|
49
|
+
"hardEnforcement|specialistEnforcement: true|false; "
|
|
50
|
+
"frontendGate: off|warn|hard; read-only without it"
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
return parser
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _status(manager: ClaudeConfigManager, args) -> int:
|
|
57
|
+
report = manager.status()
|
|
58
|
+
if getattr(args, "as_json", False):
|
|
59
|
+
print(json.dumps(report, indent=2))
|
|
60
|
+
return 0
|
|
61
|
+
_print_drift(report["drift"])
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _print_drift(drift_report: dict) -> None:
|
|
66
|
+
print(f"settings: {drift_report['settings_path']}")
|
|
67
|
+
print(f"ok: {drift_report['ok']}")
|
|
68
|
+
for finding in drift_report["findings"]:
|
|
69
|
+
print(
|
|
70
|
+
f" [{finding['status']}] {finding['where']} — "
|
|
71
|
+
f"{finding['detail']}"
|
|
72
|
+
)
|
|
73
|
+
if not drift_report["findings"]:
|
|
74
|
+
print(" no drift")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _assert(manager: ClaudeConfigManager, args) -> int:
|
|
78
|
+
return _print_report(manager.assert_ownership(), args)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _restore(manager: ClaudeConfigManager, args) -> int:
|
|
82
|
+
return _print_report(manager.restore(), args)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _harden(manager: ClaudeConfigManager, args) -> int:
|
|
86
|
+
report, grade = manager.harden()
|
|
87
|
+
code = _print_report(report, args, extra={"scan_grade": grade})
|
|
88
|
+
if not getattr(args, "as_json", False):
|
|
89
|
+
print(f"post-assert scan grade: {grade}")
|
|
90
|
+
if code:
|
|
91
|
+
return code
|
|
92
|
+
return 0 if grade in ("A", "B") else 2
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _flags(manager: ClaudeConfigManager, args) -> int:
|
|
96
|
+
if args.assignment:
|
|
97
|
+
name, separator, raw = args.assignment.partition("=")
|
|
98
|
+
if not separator or not raw.strip():
|
|
99
|
+
print("flags --set expects name=value", file=sys.stderr)
|
|
100
|
+
return 1
|
|
101
|
+
try:
|
|
102
|
+
flags = manager.set_flag(name.strip(), _parse_value(raw.strip()))
|
|
103
|
+
except ValueError as exc:
|
|
104
|
+
print(str(exc), file=sys.stderr)
|
|
105
|
+
return 1
|
|
106
|
+
else:
|
|
107
|
+
flags = manager.read_flags()
|
|
108
|
+
print(json.dumps(flags, indent=2))
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _parse_value(raw: str) -> object:
|
|
113
|
+
if raw.lower() in ("true", "false"):
|
|
114
|
+
return raw.lower() == "true"
|
|
115
|
+
return raw.lower()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _print_report(report, args=None, extra: dict | None = None) -> int:
|
|
119
|
+
if getattr(args, "as_json", False):
|
|
120
|
+
print(json.dumps({**report.to_dict(), **(extra or {})}, indent=2))
|
|
121
|
+
return 1 if report.refused or _has_refusal(report) else 0
|
|
122
|
+
if report.refused:
|
|
123
|
+
print(f"{report.verb}: refused — {report.refused}", file=sys.stderr)
|
|
124
|
+
print(
|
|
125
|
+
" nothing was written; fix or remove the file and re-run",
|
|
126
|
+
file=sys.stderr,
|
|
127
|
+
)
|
|
128
|
+
return 1
|
|
129
|
+
print(f"{report.verb}: changed={report.changed}")
|
|
130
|
+
for action in report.actions:
|
|
131
|
+
if action.action == "noop":
|
|
132
|
+
continue
|
|
133
|
+
detail = f" ({action.detail})" if action.detail else ""
|
|
134
|
+
print(f" {action.action}: {action.surface}{detail}")
|
|
135
|
+
return 1 if _has_refusal(report) else 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _has_refusal(report) -> bool:
|
|
139
|
+
return any(a.action == "refused" for a in report.actions)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
raise SystemExit(main())
|
package/core/harness/drift.py
CHANGED
|
@@ -26,6 +26,9 @@ ignore the report (the harness_scanner noise lesson).
|
|
|
26
26
|
|
|
27
27
|
from __future__ import annotations
|
|
28
28
|
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import shlex
|
|
29
32
|
import sys
|
|
30
33
|
from dataclasses import dataclass, field
|
|
31
34
|
from enum import StrEnum
|
|
@@ -187,16 +190,147 @@ def _check_hooks(
|
|
|
187
190
|
hooks = settings.get("hooks")
|
|
188
191
|
hooks = hooks if isinstance(hooks, dict) else {}
|
|
189
192
|
is_windows = (platform or sys.platform) == "win32"
|
|
193
|
+
accepted = _accepted_hook_dirs(report.settings_path, hooks_root)
|
|
190
194
|
for reg in spec.hook_registrations:
|
|
191
195
|
if reg.posix_only and is_windows:
|
|
192
196
|
continue
|
|
193
197
|
if reg.conditional and not _script_deployed(reg, hooks_root):
|
|
194
198
|
continue
|
|
195
|
-
_check_registration(report, reg, hooks.get(reg.event))
|
|
199
|
+
_check_registration(report, reg, hooks.get(reg.event), accepted)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _accepted_hook_dirs(
|
|
203
|
+
settings_path: Path, hooks_root: str | None
|
|
204
|
+
) -> frozenset[str] | None:
|
|
205
|
+
"""Directories an ArkaOS hook command may legitimately live in.
|
|
206
|
+
|
|
207
|
+
Three: what the installer writes (``~/.arkaos/config/hooks`` —
|
|
208
|
+
adapters/claude-code.js joins installDir with config/hooks;
|
|
209
|
+
omitting it read every healthy install as stale and repointed it
|
|
210
|
+
at the purgeable npx cache, QG C2 r1 Francisca B1), the
|
|
211
|
+
``~/.arkaos/lib`` snapshot, and the current resolved root.
|
|
212
|
+
Anything else with an ArkaOS basename is a STALE root — the
|
|
213
|
+
split-root failure mode basename matching hid (#439 M6).
|
|
214
|
+
|
|
215
|
+
None when the root cannot be resolved: without a reference point
|
|
216
|
+
staleness cannot be judged, and flagging everything is noise.
|
|
217
|
+
"""
|
|
218
|
+
home = settings_path.parent.parent # <home>/.claude/settings.json
|
|
219
|
+
try:
|
|
220
|
+
arkaos = paths.arkaos_home(home)
|
|
221
|
+
return frozenset(
|
|
222
|
+
_normalised(candidate)
|
|
223
|
+
for candidate in (
|
|
224
|
+
arkaos / "config" / "hooks",
|
|
225
|
+
arkaos / "lib" / "config" / "hooks",
|
|
226
|
+
paths.hooks_dir(hooks_root),
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
except OSError:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _normalised(path: Path) -> str:
|
|
234
|
+
"""Comparable form of a directory path.
|
|
235
|
+
|
|
236
|
+
Case-folded and lexically normalised: a case variant or a ``..``
|
|
237
|
+
segment names the same directory on the operator's filesystem and
|
|
238
|
+
must not read as a different root (QG C2 r1 M2). ``resolve()`` is
|
|
239
|
+
deliberately not used — it hits the filesystem and would make a
|
|
240
|
+
read-only scan depend on what happens to exist.
|
|
241
|
+
"""
|
|
242
|
+
return os.path.normcase(os.path.normpath(str(path)))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def hook_command_path(command: object) -> Path:
|
|
246
|
+
"""The script path inside a hook ``command`` string.
|
|
247
|
+
|
|
248
|
+
Operators write commands with surrounding quotes and with
|
|
249
|
+
interpreter prefixes (``bash /path/hook.sh``); matching on the raw
|
|
250
|
+
string appended a DUPLICATE registration and stripped the prefix
|
|
251
|
+
(QG C2 r1, Francisca B7). One normaliser, used by every consumer.
|
|
252
|
+
"""
|
|
253
|
+
text = str(command or "").strip()
|
|
254
|
+
if not text:
|
|
255
|
+
return Path("")
|
|
256
|
+
parts = shlex.split(text) if _splittable(text) else [text]
|
|
257
|
+
parts = _before_shell_operator(parts)
|
|
258
|
+
for token in reversed(parts):
|
|
259
|
+
if token.endswith(_HOOK_SUFFIXES):
|
|
260
|
+
return Path(token)
|
|
261
|
+
for token in reversed(parts):
|
|
262
|
+
if "/" in token or "\\" in token:
|
|
263
|
+
return Path(token)
|
|
264
|
+
return Path(parts[-1]) if parts else Path("")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
_SHELL_OPERATOR_RE = re.compile(r"[|;&>]")
|
|
268
|
+
_HOOK_SUFFIXES = (".sh", ".ps1", ".cjs")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _before_shell_operator(parts: list[str]) -> list[str]:
|
|
272
|
+
"""Tokens up to the first shell operator.
|
|
273
|
+
|
|
274
|
+
Load-bearing whenever the SECOND command or the redirect target
|
|
275
|
+
would win the scan: ``stop.sh 2>/tmp/hook-debug.sh`` ends in a
|
|
276
|
+
hook suffix, ``stop.sh && /usr/local/bin/notify.sh`` chains a
|
|
277
|
+
second script, and ``bash <dir>/stop 2>/dev/null`` has no suffix
|
|
278
|
+
at all — in each case the wrong token is read as the script, the
|
|
279
|
+
ArkaOS entry goes unrecognised, and assert appends a SECOND
|
|
280
|
+
registration that fires the hook twice (QG C2 r2 Francisca B2;
|
|
281
|
+
the ``2>/dev/null`` example first documented here was already
|
|
282
|
+
handled by the suffix scan, QG C2 r3 Eduardo).
|
|
283
|
+
|
|
284
|
+
Operators attach to the previous token as often as they stand
|
|
285
|
+
alone (``stop.sh;`` vs ``stop.sh ;``), so both forms cut.
|
|
286
|
+
"""
|
|
287
|
+
kept: list[str] = []
|
|
288
|
+
for token in parts:
|
|
289
|
+
head = _operator_head(token)
|
|
290
|
+
if head is None:
|
|
291
|
+
kept.append(token)
|
|
292
|
+
continue
|
|
293
|
+
if head:
|
|
294
|
+
kept.append(head)
|
|
295
|
+
break
|
|
296
|
+
return kept or parts
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _operator_head(token: str) -> str | None:
|
|
300
|
+
"""Text before a genuine shell operator in ``token``, else None.
|
|
301
|
+
|
|
302
|
+
An operator CHARACTER is not an operator POSITION: a directory
|
|
303
|
+
named ``R&D`` or ``a;b`` is an ordinary path, and cutting there
|
|
304
|
+
made the manager stop recognising the entry it had just written —
|
|
305
|
+
assert went non-idempotent, appending a Stop group per run
|
|
306
|
+
(QG C2 r4, Francisca B2; the regression came from the r3 fix for
|
|
307
|
+
the duplication bug, not from the original code). A cut is
|
|
308
|
+
genuine only where a shell would see one: at the start of the
|
|
309
|
+
token, right after a hook script, or after a file-descriptor
|
|
310
|
+
number.
|
|
311
|
+
"""
|
|
312
|
+
match = _SHELL_OPERATOR_RE.search(token)
|
|
313
|
+
if match is None:
|
|
314
|
+
return None
|
|
315
|
+
head = token[: match.start()]
|
|
316
|
+
if not head or head.isdigit() or head.endswith(_HOOK_SUFFIXES):
|
|
317
|
+
return head
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _splittable(text: str) -> bool:
|
|
322
|
+
try:
|
|
323
|
+
shlex.split(text)
|
|
324
|
+
return True
|
|
325
|
+
except ValueError:
|
|
326
|
+
return False
|
|
196
327
|
|
|
197
328
|
|
|
198
329
|
def _check_registration(
|
|
199
|
-
report: DriftReport,
|
|
330
|
+
report: DriftReport,
|
|
331
|
+
reg: HookRegistration,
|
|
332
|
+
entries: Any,
|
|
333
|
+
accepted: frozenset[str] | None,
|
|
200
334
|
) -> None:
|
|
201
335
|
where = f"hooks.{reg.event}" + (
|
|
202
336
|
f"[matcher={reg.matcher}]" if reg.matcher else ""
|
|
@@ -210,14 +344,31 @@ def _check_registration(
|
|
|
210
344
|
)
|
|
211
345
|
)
|
|
212
346
|
return
|
|
347
|
+
for detail in entry_divergences(entry, reg, accepted):
|
|
348
|
+
report.findings.append(
|
|
349
|
+
DriftFinding("settings:hooks", DriftStatus.DIVERGED, where, detail)
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def entry_divergences(
|
|
354
|
+
entry: dict, reg: HookRegistration, accepted: frozenset[str] | None
|
|
355
|
+
) -> list[str]:
|
|
356
|
+
"""Why an existing ArkaOS entry diverges from spec — [] when clean.
|
|
357
|
+
|
|
358
|
+
Shared with the C2 manager so drift and repair can never disagree
|
|
359
|
+
about what counts as divergent.
|
|
360
|
+
"""
|
|
361
|
+
divergences = []
|
|
213
362
|
timeout = entry.get("timeout")
|
|
214
363
|
if timeout != reg.timeout:
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
364
|
+
divergences.append(f"timeout is {timeout!r}, spec says {reg.timeout}")
|
|
365
|
+
command_dir = hook_command_path(entry.get("command")).parent
|
|
366
|
+
if accepted is not None and _normalised(command_dir) not in accepted:
|
|
367
|
+
divergences.append(
|
|
368
|
+
f"stale-root: {reg.script} points at {command_dir}, not "
|
|
369
|
+
f"the current ArkaOS hooks dir"
|
|
220
370
|
)
|
|
371
|
+
return divergences
|
|
221
372
|
|
|
222
373
|
|
|
223
374
|
def _find_entry(reg: HookRegistration, entries: Any) -> dict | None:
|
|
@@ -233,8 +384,8 @@ def _find_entry(reg: HookRegistration, entries: Any) -> dict | None:
|
|
|
233
384
|
for inner in group.get("hooks") or []:
|
|
234
385
|
if not isinstance(inner, dict):
|
|
235
386
|
continue
|
|
236
|
-
|
|
237
|
-
if
|
|
387
|
+
name = hook_command_path(inner.get("command")).name
|
|
388
|
+
if name and name in wanted:
|
|
238
389
|
return inner
|
|
239
390
|
return None
|
|
240
391
|
|