arkaos 4.45.0 → 4.47.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/__init__.py +20 -0
- package/core/egress/allowlist.py +123 -0
- package/core/egress/audit.py +127 -0
- package/core/egress/policy.py +261 -0
- package/core/egress/redact.py +46 -0
- package/core/governance/evidence_checks.py +116 -37
- package/core/harness/__init__.py +22 -0
- package/core/harness/cli.py +143 -0
- package/core/harness/drift.py +468 -0
- package/core/harness/json_store.py +119 -0
- package/core/harness/manager.py +577 -0
- package/core/harness/manifest.py +91 -0
- package/core/harness/paths.py +72 -0
- package/core/harness/spec.py +182 -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
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""Spec-vs-disk drift detection for the Claude Code harness (PR-C1).
|
|
2
|
+
|
|
3
|
+
``scan()`` reads the operator's ``settings.json`` and reports where it
|
|
4
|
+
diverges from ``core.harness.spec``. Read-only by contract — the same
|
|
5
|
+
rule ``harness_scanner`` holds: a scan never mutates what it measures,
|
|
6
|
+
prints nothing, exits nothing, and NEVER raises on hostile input (a
|
|
7
|
+
truncated or binary settings file is a finding, not a traceback).
|
|
8
|
+
|
|
9
|
+
Drift vocabulary, aligned with the ownership policies:
|
|
10
|
+
|
|
11
|
+
- ``missing`` — an ArkaOS-managed entry is absent: an owned entry
|
|
12
|
+
(own / own-subset), a seed surface that was never seeded, or the
|
|
13
|
+
settings file itself.
|
|
14
|
+
- ``diverged`` — an ArkaOS-owned entry is present but altered.
|
|
15
|
+
- ``adopted`` — a ``seed`` surface the operator changed; the operator
|
|
16
|
+
won, drift REPORTS it and C2's assert, once it ships, must never
|
|
17
|
+
revert it.
|
|
18
|
+
- ``unreadable`` — the settings file could not be used, or the scan
|
|
19
|
+
itself could not run (unknown runtime, internal failure).
|
|
20
|
+
|
|
21
|
+
Operator additions — extra hook entries, extra deny rules, unknown
|
|
22
|
+
settings keys — are NOT drift. Under own-subset the operator's material
|
|
23
|
+
is legitimate content, and flagging it would teach the operator to
|
|
24
|
+
ignore the report (the harness_scanner noise lesson).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import shlex
|
|
32
|
+
import sys
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from enum import StrEnum
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from core.harness import json_store, paths
|
|
39
|
+
from core.harness.spec import HookRegistration, RuntimeSpec, spec_for
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DriftStatus(StrEnum):
|
|
43
|
+
"""Kind of divergence between spec and disk."""
|
|
44
|
+
|
|
45
|
+
MISSING = "missing"
|
|
46
|
+
DIVERGED = "diverged"
|
|
47
|
+
ADOPTED = "adopted"
|
|
48
|
+
UNREADABLE = "unreadable"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class DriftFinding:
|
|
53
|
+
"""One divergence, with enough detail to act on."""
|
|
54
|
+
|
|
55
|
+
surface: str
|
|
56
|
+
status: DriftStatus
|
|
57
|
+
where: str
|
|
58
|
+
detail: str
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict:
|
|
61
|
+
return {
|
|
62
|
+
"surface": self.surface,
|
|
63
|
+
"status": self.status.value,
|
|
64
|
+
"where": self.where,
|
|
65
|
+
"detail": self.detail,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class DriftReport:
|
|
71
|
+
"""Result of one drift scan against one settings file."""
|
|
72
|
+
|
|
73
|
+
settings_path: Path
|
|
74
|
+
runtime: str
|
|
75
|
+
findings: list[DriftFinding] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def ok(self) -> bool:
|
|
79
|
+
"""True when nothing ArkaOS-owned is missing, diverged or unreadable.
|
|
80
|
+
|
|
81
|
+
``adopted`` findings do not fail the report — the operator
|
|
82
|
+
winning a seed surface is recorded state, not a defect.
|
|
83
|
+
"""
|
|
84
|
+
return not [
|
|
85
|
+
f
|
|
86
|
+
for f in self.findings
|
|
87
|
+
if f.status is not DriftStatus.ADOPTED
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> dict:
|
|
91
|
+
return {
|
|
92
|
+
"settings_path": str(self.settings_path),
|
|
93
|
+
"runtime": self.runtime,
|
|
94
|
+
"ok": self.ok,
|
|
95
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def scan(
|
|
100
|
+
home: Path | None = None,
|
|
101
|
+
runtime: str = "claude-code",
|
|
102
|
+
platform: str | None = None,
|
|
103
|
+
hooks_root: str | None = None,
|
|
104
|
+
) -> DriftReport:
|
|
105
|
+
"""Compare the harness on disk to the runtime spec. Never raises.
|
|
106
|
+
|
|
107
|
+
``platform`` defaults to ``sys.platform`` (``win32`` skips
|
|
108
|
+
posix-only registrations); ``hooks_root`` overrides the ArkaOS root
|
|
109
|
+
used to evaluate ``conditional`` registrations.
|
|
110
|
+
"""
|
|
111
|
+
settings_path = paths.claude_settings_path(home)
|
|
112
|
+
report = DriftReport(settings_path=settings_path, runtime=runtime)
|
|
113
|
+
spec = _spec_or_finding(report, runtime)
|
|
114
|
+
if spec is None:
|
|
115
|
+
return report
|
|
116
|
+
try:
|
|
117
|
+
_scan_into(report, spec, platform, hooks_root)
|
|
118
|
+
except Exception as exc: # never-raises boundary
|
|
119
|
+
report.findings.append(
|
|
120
|
+
DriftFinding(
|
|
121
|
+
"settings", DriftStatus.UNREADABLE, str(settings_path),
|
|
122
|
+
f"scan aborted: {type(exc).__name__}: {exc}",
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return report
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _spec_or_finding(report: DriftReport, runtime: str) -> RuntimeSpec | None:
|
|
129
|
+
"""Resolve the runtime spec, or record why the scan cannot run.
|
|
130
|
+
|
|
131
|
+
An unknown runtime is a caller problem, not a settings problem —
|
|
132
|
+
naming the settings file would send the operator to debug the
|
|
133
|
+
wrong thing.
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
return spec_for(runtime)
|
|
137
|
+
except ValueError as exc:
|
|
138
|
+
report.findings.append(
|
|
139
|
+
DriftFinding("runtime", DriftStatus.UNREADABLE, runtime, str(exc))
|
|
140
|
+
)
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# Operator-facing phrasing per load error; the installer remediation is
|
|
145
|
+
# only true for the missing case — re-running it does not repair a
|
|
146
|
+
# corrupted or oversized file.
|
|
147
|
+
_LOAD_ERROR_DETAIL = {
|
|
148
|
+
"missing": "settings file missing; run the installer to seed it",
|
|
149
|
+
"invalid-json": "settings file is not valid JSON",
|
|
150
|
+
"not-an-object": "settings file is not a JSON object",
|
|
151
|
+
"unreadable": "settings file could not be read",
|
|
152
|
+
"oversized": "settings file exceeds the 2 MiB read ceiling",
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _scan_into(
|
|
157
|
+
report: DriftReport,
|
|
158
|
+
spec: RuntimeSpec,
|
|
159
|
+
platform: str | None,
|
|
160
|
+
hooks_root: str | None,
|
|
161
|
+
) -> None:
|
|
162
|
+
loaded = json_store.load_json(report.settings_path)
|
|
163
|
+
if not loaded.ok:
|
|
164
|
+
status = (
|
|
165
|
+
DriftStatus.MISSING
|
|
166
|
+
if loaded.error == "missing"
|
|
167
|
+
else DriftStatus.UNREADABLE
|
|
168
|
+
)
|
|
169
|
+
detail = _LOAD_ERROR_DETAIL.get(
|
|
170
|
+
loaded.error or "", f"settings file unusable: {loaded.error}"
|
|
171
|
+
)
|
|
172
|
+
report.findings.append(
|
|
173
|
+
DriftFinding("settings", status, str(report.settings_path), detail)
|
|
174
|
+
)
|
|
175
|
+
return
|
|
176
|
+
settings = loaded.data or {}
|
|
177
|
+
_check_hooks(report, spec, settings, platform, hooks_root)
|
|
178
|
+
_check_hard_deny(report, spec, settings)
|
|
179
|
+
_check_status_line(report, settings)
|
|
180
|
+
_check_worktree(report, settings)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _check_hooks(
|
|
184
|
+
report: DriftReport,
|
|
185
|
+
spec: RuntimeSpec,
|
|
186
|
+
settings: dict[str, Any],
|
|
187
|
+
platform: str | None,
|
|
188
|
+
hooks_root: str | None,
|
|
189
|
+
) -> None:
|
|
190
|
+
hooks = settings.get("hooks")
|
|
191
|
+
hooks = hooks if isinstance(hooks, dict) else {}
|
|
192
|
+
is_windows = (platform or sys.platform) == "win32"
|
|
193
|
+
accepted = _accepted_hook_dirs(report.settings_path, hooks_root)
|
|
194
|
+
for reg in spec.hook_registrations:
|
|
195
|
+
if reg.posix_only and is_windows:
|
|
196
|
+
continue
|
|
197
|
+
if reg.conditional and not _script_deployed(reg, hooks_root):
|
|
198
|
+
continue
|
|
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
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _check_registration(
|
|
330
|
+
report: DriftReport,
|
|
331
|
+
reg: HookRegistration,
|
|
332
|
+
entries: Any,
|
|
333
|
+
accepted: frozenset[str] | None,
|
|
334
|
+
) -> None:
|
|
335
|
+
where = f"hooks.{reg.event}" + (
|
|
336
|
+
f"[matcher={reg.matcher}]" if reg.matcher else ""
|
|
337
|
+
)
|
|
338
|
+
entry = _find_entry(reg, entries)
|
|
339
|
+
if entry is None:
|
|
340
|
+
report.findings.append(
|
|
341
|
+
DriftFinding(
|
|
342
|
+
"settings:hooks", DriftStatus.MISSING, where,
|
|
343
|
+
f"no {reg.script} entry registered for this event",
|
|
344
|
+
)
|
|
345
|
+
)
|
|
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 = []
|
|
362
|
+
timeout = entry.get("timeout")
|
|
363
|
+
if timeout != reg.timeout:
|
|
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"
|
|
370
|
+
)
|
|
371
|
+
return divergences
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _find_entry(reg: HookRegistration, entries: Any) -> dict | None:
|
|
375
|
+
"""The ArkaOS inner hook entry for ``reg``, or None."""
|
|
376
|
+
if not isinstance(entries, list):
|
|
377
|
+
return None
|
|
378
|
+
wanted = {f"{reg.script}.sh", f"{reg.script}.ps1", f"{reg.script}.cjs"}
|
|
379
|
+
for group in entries:
|
|
380
|
+
if not isinstance(group, dict):
|
|
381
|
+
continue
|
|
382
|
+
if (group.get("matcher") or None) != reg.matcher:
|
|
383
|
+
continue
|
|
384
|
+
for inner in group.get("hooks") or []:
|
|
385
|
+
if not isinstance(inner, dict):
|
|
386
|
+
continue
|
|
387
|
+
name = hook_command_path(inner.get("command")).name
|
|
388
|
+
if name and name in wanted:
|
|
389
|
+
return inner
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _script_deployed(reg: HookRegistration, hooks_root: str | None) -> bool:
|
|
394
|
+
try:
|
|
395
|
+
hooks = paths.hooks_dir(hooks_root)
|
|
396
|
+
return any(
|
|
397
|
+
(hooks / f"{reg.script}{ext}").is_file()
|
|
398
|
+
for ext in (".sh", ".ps1", ".cjs")
|
|
399
|
+
)
|
|
400
|
+
except OSError:
|
|
401
|
+
return False
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _check_hard_deny(
|
|
405
|
+
report: DriftReport, spec: RuntimeSpec, settings: dict[str, Any]
|
|
406
|
+
) -> None:
|
|
407
|
+
auto_mode = settings.get("autoMode")
|
|
408
|
+
auto_mode = auto_mode if isinstance(auto_mode, dict) else {}
|
|
409
|
+
rules = auto_mode.get("hard_deny")
|
|
410
|
+
rules = rules if isinstance(rules, list) else []
|
|
411
|
+
present = {r for r in rules if isinstance(r, str)}
|
|
412
|
+
absent = [r for r in spec.hard_deny_rules if r not in present]
|
|
413
|
+
if not absent:
|
|
414
|
+
return
|
|
415
|
+
examples = ", ".join(absent[:3])
|
|
416
|
+
report.findings.append(
|
|
417
|
+
DriftFinding(
|
|
418
|
+
"settings:autoMode.hard_deny", DriftStatus.MISSING,
|
|
419
|
+
"autoMode.hard_deny",
|
|
420
|
+
f"{len(absent)} of {len(spec.hard_deny_rules)} curated deny "
|
|
421
|
+
f"rules absent (e.g. {examples})",
|
|
422
|
+
)
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _check_status_line(report: DriftReport, settings: dict[str, Any]) -> None:
|
|
427
|
+
status_line = settings.get("statusLine")
|
|
428
|
+
if status_line is None:
|
|
429
|
+
report.findings.append(
|
|
430
|
+
DriftFinding(
|
|
431
|
+
"settings:statusLine", DriftStatus.MISSING, "statusLine",
|
|
432
|
+
"status line not seeded",
|
|
433
|
+
)
|
|
434
|
+
)
|
|
435
|
+
elif not _is_arkaos_statusline(status_line):
|
|
436
|
+
report.findings.append(
|
|
437
|
+
DriftFinding(
|
|
438
|
+
"settings:statusLine", DriftStatus.ADOPTED, "statusLine",
|
|
439
|
+
"operator-configured status line; seed policy adopts it",
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _check_worktree(report: DriftReport, settings: dict[str, Any]) -> None:
|
|
445
|
+
worktree = settings.get("worktree")
|
|
446
|
+
if worktree is None:
|
|
447
|
+
report.findings.append(
|
|
448
|
+
DriftFinding(
|
|
449
|
+
"settings:worktree", DriftStatus.MISSING, "worktree",
|
|
450
|
+
"worktree.baseRef default not seeded",
|
|
451
|
+
)
|
|
452
|
+
)
|
|
453
|
+
elif not (
|
|
454
|
+
isinstance(worktree, dict) and worktree.get("baseRef") == "head"
|
|
455
|
+
):
|
|
456
|
+
report.findings.append(
|
|
457
|
+
DriftFinding(
|
|
458
|
+
"settings:worktree", DriftStatus.ADOPTED, "worktree",
|
|
459
|
+
"operator-configured worktree; seed policy adopts it",
|
|
460
|
+
)
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _is_arkaos_statusline(status_line: Any) -> bool:
|
|
465
|
+
if not isinstance(status_line, dict):
|
|
466
|
+
return False
|
|
467
|
+
command = str(status_line.get("command", ""))
|
|
468
|
+
return Path(command.strip('"')).name in ("statusline.sh", "statusline.ps1")
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Tolerant JSON reads, atomic writes, and order-preserving merges.
|
|
2
|
+
|
|
3
|
+
The harness surfaces this package reads (``settings.json``,
|
|
4
|
+
``ownership.json``, ``hard-deny.json``) are operator-owned files that
|
|
5
|
+
can be truncated, binary, or hand-edited into invalid JSON at any time.
|
|
6
|
+
A loader that raises on that input takes the whole caller down with it —
|
|
7
|
+
the ``harness_scanner`` contract applies here too: hostile input is a
|
|
8
|
+
reported condition, never a traceback.
|
|
9
|
+
|
|
10
|
+
The writer is the C2 primitive: same-directory tmp file + ``os.replace``
|
|
11
|
+
so a crash mid-write leaves the previous content intact, and the target
|
|
12
|
+
file's permission bits survive the replacement.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import tempfile
|
|
21
|
+
from collections.abc import Iterable
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
# Same ceiling as core.governance.harness_scanner: a settings file
|
|
27
|
+
# larger than this is itself a finding, not something to slurp.
|
|
28
|
+
MAX_JSON_BYTES = 2 * 1024 * 1024
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class LoadResult:
|
|
33
|
+
"""Outcome of a tolerant JSON load.
|
|
34
|
+
|
|
35
|
+
``data`` is the parsed top-level object on success, ``None``
|
|
36
|
+
otherwise; ``error`` names the failure (``missing``, ``oversized``,
|
|
37
|
+
``unreadable``, ``invalid-json``, ``not-an-object``) or is ``None``.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
data: dict[str, Any] | None
|
|
41
|
+
error: str | None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def ok(self) -> bool:
|
|
45
|
+
return self.error is None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_json(path: Path) -> LoadResult:
|
|
49
|
+
"""Read ``path`` as a JSON object without ever raising."""
|
|
50
|
+
try:
|
|
51
|
+
if not path.is_file():
|
|
52
|
+
return LoadResult(None, "missing")
|
|
53
|
+
if path.stat().st_size > MAX_JSON_BYTES:
|
|
54
|
+
return LoadResult(None, "oversized")
|
|
55
|
+
raw = path.read_text(encoding="utf-8", errors="strict")
|
|
56
|
+
except (OSError, UnicodeDecodeError):
|
|
57
|
+
return LoadResult(None, "unreadable")
|
|
58
|
+
try:
|
|
59
|
+
data = json.loads(raw)
|
|
60
|
+
except json.JSONDecodeError:
|
|
61
|
+
return LoadResult(None, "invalid-json")
|
|
62
|
+
if not isinstance(data, dict):
|
|
63
|
+
return LoadResult(None, "not-an-object")
|
|
64
|
+
return LoadResult(data, None)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def write_json_atomic(path: Path, data: dict[str, Any]) -> None:
|
|
68
|
+
"""Write ``data`` to ``path`` via same-dir tmp + ``os.replace``.
|
|
69
|
+
|
|
70
|
+
Raises on failure (callers decide policy); guarantees the target is
|
|
71
|
+
either the previous content or the complete new content, never a
|
|
72
|
+
partial write. Existing permission bits on ``path`` are preserved.
|
|
73
|
+
"""
|
|
74
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
previous_mode = _current_mode(path)
|
|
76
|
+
payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
77
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
78
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
|
79
|
+
)
|
|
80
|
+
try:
|
|
81
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
82
|
+
handle.write(payload)
|
|
83
|
+
handle.flush()
|
|
84
|
+
os.fsync(handle.fileno())
|
|
85
|
+
if previous_mode is not None:
|
|
86
|
+
os.chmod(tmp_name, previous_mode)
|
|
87
|
+
os.replace(tmp_name, path)
|
|
88
|
+
except BaseException:
|
|
89
|
+
_unlink_quiet(tmp_name)
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def merge_unique(*sequences: Iterable[str]) -> list[str]:
|
|
94
|
+
"""Order-preserving union; the FIRST occurrence of a value wins.
|
|
95
|
+
|
|
96
|
+
Callers encode precedence by argument order — putting the
|
|
97
|
+
operator's entries first keeps them ahead of shipped defaults, the
|
|
98
|
+
same contract as ``mergeUnique`` in ``installer/hard-deny.js``.
|
|
99
|
+
"""
|
|
100
|
+
seen: set[str] = set()
|
|
101
|
+
merged: list[str] = []
|
|
102
|
+
for sequence in sequences:
|
|
103
|
+
for value in sequence:
|
|
104
|
+
if value not in seen:
|
|
105
|
+
seen.add(value)
|
|
106
|
+
merged.append(value)
|
|
107
|
+
return merged
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _current_mode(path: Path) -> int | None:
|
|
111
|
+
try:
|
|
112
|
+
return path.stat().st_mode & 0o7777
|
|
113
|
+
except OSError:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _unlink_quiet(name: str) -> None:
|
|
118
|
+
with contextlib.suppress(OSError):
|
|
119
|
+
os.unlink(name)
|