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.
Files changed (44) hide show
  1. package/THE-ARKAOS-GUIDE.md +1 -1
  2. package/VERSION +1 -1
  3. package/core/egress/policy.py +84 -32
  4. package/core/harness/cli.py +143 -0
  5. package/core/harness/drift.py +160 -9
  6. package/core/harness/manager.py +577 -0
  7. package/core/harness/manifest.py +5 -1
  8. package/core/kb/__init__.py +9 -0
  9. package/core/kb/nlm_client.py +883 -0
  10. package/harness/codex/AGENTS.md +1 -1
  11. package/harness/copilot/copilot-instructions.md +1 -1
  12. package/harness/cursor/rules/arkaos.mdc +2 -2
  13. package/harness/gemini/GEMINI.md +1 -1
  14. package/harness/opencode/AGENTS.md +1 -1
  15. package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
  16. package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
  17. package/harness/opencode/agents/arka-cfo-helena.md +1 -1
  18. package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
  19. package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
  20. package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
  21. package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
  22. package/harness/opencode/agents/arka-coo-sofia.md +1 -1
  23. package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
  24. package/harness/opencode/agents/arka-cqo-marta.md +1 -1
  25. package/harness/opencode/agents/arka-cto-marco.md +1 -1
  26. package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
  27. package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
  28. package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
  29. package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
  30. package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
  31. package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
  32. package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
  33. package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
  34. package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
  35. package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
  36. package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
  37. package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
  38. package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
  39. package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
  40. package/harness/zed/.rules +1 -1
  41. package/installer/cli.js +27 -0
  42. package/knowledge/skills-manifest.json +1 -1
  43. package/package.json +1 -1
  44. package/pyproject.toml +1 -1
@@ -1,6 +1,6 @@
1
1
  # The ArkaOS Guide
2
2
 
3
- > v4.46.0 — 89 agents, 17 departments, 332 skills, 297 commands, 19 ADRs.
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.46.0
1
+ 4.48.0
@@ -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 and the default allowlist /
103
- audit locations; ``now`` pins allowlist expiry for tests.
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
- # Digest computed ONCE, before any failure path, so no handler
107
- # ever re-executes the operation that failed (QG D1 r2 E-B1).
108
- payload_digest = _sha256(text) if isinstance(text, str) else ""
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 = EgressDecision(
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 EgressDecision(
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=_sha256(text),
191
+ payload_sha256=payload_digest(text),
177
192
  )
178
- clean, failure = _redacted(text, config_path)
179
- if failure is not None:
180
- decision.findings.append(failure)
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, config_path, home, allowlist_path, now
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 = _sha256(clean)
207
+ decision.redacted_sha256 = payload_digest(clean)
189
208
  return decision
190
209
 
191
210
 
192
- def _sha256(text: str) -> str:
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 _redacted(
202
- text: str, config_path: Path | None
203
- ) -> tuple[str | None, Finding | None]:
204
- """``(clean_text, None)`` or ``(None, denial_finding)``.
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
- return clean, None
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 None, Finding("redaction-config-missing", "")
266
+ return Finding("redaction-config-missing", "")
215
267
  except Exception as exc:
216
- return None, Finding("redaction-failed", type(exc).__name__)
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())
@@ -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, reg: HookRegistration, entries: Any
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
- report.findings.append(
216
- DriftFinding(
217
- "settings:hooks", DriftStatus.DIVERGED, where,
218
- f"timeout is {timeout!r}, spec says {reg.timeout}",
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
- command = str(inner.get("command", ""))
237
- if command and Path(command).name in wanted:
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