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.
Files changed (51) hide show
  1. package/THE-ARKAOS-GUIDE.md +1 -1
  2. package/VERSION +1 -1
  3. package/core/egress/__init__.py +20 -0
  4. package/core/egress/allowlist.py +123 -0
  5. package/core/egress/audit.py +127 -0
  6. package/core/egress/policy.py +261 -0
  7. package/core/egress/redact.py +46 -0
  8. package/core/governance/evidence_checks.py +116 -37
  9. package/core/harness/__init__.py +22 -0
  10. package/core/harness/cli.py +143 -0
  11. package/core/harness/drift.py +468 -0
  12. package/core/harness/json_store.py +119 -0
  13. package/core/harness/manager.py +577 -0
  14. package/core/harness/manifest.py +91 -0
  15. package/core/harness/paths.py +72 -0
  16. package/core/harness/spec.py +182 -0
  17. package/harness/codex/AGENTS.md +1 -1
  18. package/harness/copilot/copilot-instructions.md +1 -1
  19. package/harness/cursor/rules/arkaos.mdc +2 -2
  20. package/harness/gemini/GEMINI.md +1 -1
  21. package/harness/opencode/AGENTS.md +1 -1
  22. package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
  23. package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
  24. package/harness/opencode/agents/arka-cfo-helena.md +1 -1
  25. package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
  26. package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
  27. package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
  28. package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
  29. package/harness/opencode/agents/arka-coo-sofia.md +1 -1
  30. package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
  31. package/harness/opencode/agents/arka-cqo-marta.md +1 -1
  32. package/harness/opencode/agents/arka-cto-marco.md +1 -1
  33. package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
  34. package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
  35. package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
  36. package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
  37. package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
  38. package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
  39. package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
  40. package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
  41. package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
  42. package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
  43. package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
  44. package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
  45. package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
  46. package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
  47. package/harness/zed/.rules +1 -1
  48. package/installer/cli.js +27 -0
  49. package/knowledge/skills-manifest.json +1 -1
  50. package/package.json +1 -1
  51. 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)