arkaos 4.45.0 → 4.46.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 (48) 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/drift.py +317 -0
  11. package/core/harness/json_store.py +119 -0
  12. package/core/harness/manifest.py +87 -0
  13. package/core/harness/paths.py +72 -0
  14. package/core/harness/spec.py +182 -0
  15. package/harness/codex/AGENTS.md +1 -1
  16. package/harness/copilot/copilot-instructions.md +1 -1
  17. package/harness/cursor/rules/arkaos.mdc +2 -2
  18. package/harness/gemini/GEMINI.md +1 -1
  19. package/harness/opencode/AGENTS.md +1 -1
  20. package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
  21. package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
  22. package/harness/opencode/agents/arka-cfo-helena.md +1 -1
  23. package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
  24. package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
  25. package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
  26. package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
  27. package/harness/opencode/agents/arka-coo-sofia.md +1 -1
  28. package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
  29. package/harness/opencode/agents/arka-cqo-marta.md +1 -1
  30. package/harness/opencode/agents/arka-cto-marco.md +1 -1
  31. package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
  32. package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
  33. package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
  34. package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
  35. package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
  36. package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
  37. package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
  38. package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
  39. package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
  40. package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
  41. package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
  42. package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
  43. package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
  44. package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
  45. package/harness/zed/.rules +1 -1
  46. package/knowledge/skills-manifest.json +1 -1
  47. package/package.json +1 -1
  48. package/pyproject.toml +1 -1
@@ -97,6 +97,19 @@ _SECURITY_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
97
97
  ("curl-pipe-shell", re.compile(r"curl[^|\n]*\|\s*(?:ba|z)?sh\b")),
98
98
  )
99
99
 
100
+ # Sanctioned per-line suppression: `arka:sec-ok(<pattern-id>): <reason>`.
101
+ # A line that DEFINES a dangerous pattern — a deny rule, an egress
102
+ # scanner signature — necessarily contains the pattern it names, and a
103
+ # sweep with no escape valve forces either scanner evasion (splitting
104
+ # the literal) or a permanently red gate. The valve is deliberately
105
+ # narrow: the id must name the exact matched pattern and the reason
106
+ # must be non-empty. The reason is a formality for the record; the
107
+ # CONTROL is visibility — every suppression is carried in the
108
+ # structured `suppressions` / `suppressed_count` fields of the
109
+ # CheckResult (immune to summary truncation), and the string summary
110
+ # ends with a `(+N more suppressed)` marker when the listing is capped.
111
+ _SEC_OK_RE = re.compile(r"arka:sec-ok\(([a-z0-9-]+)\):\s*(\S.+)")
112
+
100
113
 
101
114
  @dataclass
102
115
  class CheckResult:
@@ -109,6 +122,11 @@ class CheckResult:
109
122
  exit_code: int | None
110
123
  summary: str
111
124
  details_path: str | None = None
125
+ # security-grep only: the FULL suppression record, structured so it
126
+ # bypasses summary truncation entirely. Empty for other checks and
127
+ # for pre-existing corpus records.
128
+ suppressions: list[str] = field(default_factory=list)
129
+ suppressed_count: int = 0
112
130
 
113
131
 
114
132
  @dataclass
@@ -509,26 +527,48 @@ def _check_coverage(
509
527
  return _skip("coverage", "no coverage.xml or junit.xml on disk")
510
528
 
511
529
 
512
- def _grep_lines(path: Path, lines: list[str]) -> list[str]:
513
- hits = []
514
- for lineno_or_text in lines:
515
- for name, pattern in _SECURITY_PATTERNS:
516
- if pattern.search(lineno_or_text):
517
- hits.append(f"{path} [{name}]: {lineno_or_text.strip()[:120]}")
518
- return hits
530
+ def _line_matches(line: str) -> tuple[list[str], list[str]]:
531
+ """(flagged, suppressed) pattern names for one line.
532
+
533
+ A pattern is suppressed only when the line carries an
534
+ ``arka:sec-ok(<id>): <reason>`` annotation whose id names EXACTLY
535
+ that pattern and whose reason is non-empty. A wrong id, a bare
536
+ annotation, or an empty reason suppresses nothing.
537
+ """
538
+ ok = _SEC_OK_RE.search(line)
539
+ allowed = ok.group(1) if ok else None
540
+ flagged: list[str] = []
541
+ suppressed: list[str] = []
542
+ for name, pattern in _SECURITY_PATTERNS:
543
+ if pattern.search(line):
544
+ (suppressed if name == allowed else flagged).append(name)
545
+ return flagged, suppressed
546
+
547
+
548
+ def _grep_lines(
549
+ path: Path, lines: list[tuple[int, str]]
550
+ ) -> tuple[list[str], list[str]]:
551
+ hits, suppressed = [], []
552
+ for lineno, text in lines:
553
+ flagged, quiet = _line_matches(text)
554
+ hits.extend(
555
+ f"{path}:{lineno} [{n}]: {text.strip()[:120]}" for n in flagged
556
+ )
557
+ suppressed.extend(f"{path}:{lineno} [{n}]" for n in quiet)
558
+ return hits, suppressed
519
559
 
520
560
 
521
- def _grep_file(path: Path) -> list[str]:
561
+ def _grep_file(path: Path) -> tuple[list[str], list[str]]:
522
562
  try:
523
563
  text = path.read_text(encoding="utf-8", errors="ignore")
524
564
  except OSError:
525
- return []
526
- hits = []
565
+ return [], []
566
+ hits, suppressed = [], []
527
567
  for lineno, line in enumerate(text.splitlines(), start=1):
528
- for name, pattern in _SECURITY_PATTERNS:
529
- if pattern.search(line):
530
- hits.append(f"{path}:{lineno} [{name}]")
531
- return hits
568
+ flagged, quiet = _line_matches(line)
569
+ hits.extend(f"{path}:{lineno} [{n}]" for n in flagged)
570
+ suppressed.extend(f"{path}:{lineno} [{n}]" for n in quiet)
571
+ return hits, suppressed
532
572
 
533
573
 
534
574
  def _diff_base(project_dir: Path) -> str | None:
@@ -543,11 +583,18 @@ def _diff_base(project_dir: Path) -> str | None:
543
583
  return None
544
584
 
545
585
 
546
- def _added_lines(project_dir: Path, base: str, name: str) -> list[str] | None:
547
- """Lines ADDED by this change (committed + working tree) vs base.
586
+ _HUNK_HEADER_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
587
+
588
+
589
+ def _added_lines(
590
+ project_dir: Path, base: str, name: str
591
+ ) -> list[tuple[int, str]] | None:
592
+ """(line number, text) pairs ADDED by this change vs base.
548
593
 
549
- Returns None when git cannot answer — callers fall back to the
550
- whole-file scan rather than silently passing.
594
+ Line numbers come from the ``+`` side of the ``-U0`` hunk headers,
595
+ so findings carry a location in both scan modes. Returns None when
596
+ git cannot answer — callers fall back to the whole-file scan
597
+ rather than silently passing.
551
598
  """
552
599
  proc = subprocess.run(
553
600
  ["git", "diff", "-U0", base, "--", name],
@@ -555,11 +602,16 @@ def _added_lines(project_dir: Path, base: str, name: str) -> list[str] | None:
555
602
  )
556
603
  if proc.returncode != 0:
557
604
  return None
558
- return [
559
- line[1:]
560
- for line in proc.stdout.splitlines()
561
- if line.startswith("+") and not line.startswith("+++")
562
- ]
605
+ added: list[tuple[int, str]] = []
606
+ lineno = 0
607
+ for line in proc.stdout.splitlines():
608
+ header = _HUNK_HEADER_RE.match(line)
609
+ if header:
610
+ lineno = int(header.group(1))
611
+ elif line.startswith("+") and not line.startswith("+++"):
612
+ added.append((lineno, line[1:]))
613
+ lineno += 1
614
+ return added
563
615
 
564
616
 
565
617
  def _check_security_grep(
@@ -578,29 +630,56 @@ def _check_security_grep(
578
630
  if not changed:
579
631
  return _skip("security-grep", "no changed files provided")
580
632
  base = _diff_base(project_dir)
581
- hits: list[str] = []
633
+ hits, suppressed = [], []
582
634
  mode = "added-lines" if base else "whole-file"
583
635
  for name in changed:
584
- path = Path(name)
585
- if not path.is_absolute():
586
- path = project_dir / name
587
- if not path.is_file():
636
+ path = _resolve_changed_file(project_dir, name)
637
+ if path is None:
588
638
  continue
589
639
  added = _added_lines(project_dir, base, name) if base else None
590
- if added is None:
591
- hits.extend(_grep_file(path))
592
- else:
593
- hits.extend(_grep_lines(path, added))
640
+ found, quiet = (
641
+ _grep_file(path) if added is None else _grep_lines(path, added)
642
+ )
643
+ hits.extend(found)
644
+ suppressed.extend(quiet)
645
+ return CheckResult(
646
+ check="security-grep", ran=True, passed=not hits,
647
+ command=f"security-grep ({mode}) over {len(changed)} changed file(s)",
648
+ exit_code=None, summary=_tail(_grep_summary(hits, suppressed)),
649
+ suppressions=list(suppressed), suppressed_count=len(suppressed),
650
+ )
651
+
652
+
653
+ def _resolve_changed_file(project_dir: Path, name: str) -> Path | None:
654
+ path = Path(name)
655
+ if not path.is_absolute():
656
+ path = project_dir / name
657
+ return path if path.is_file() else None
658
+
659
+
660
+ def _grep_summary(hits: list[str], suppressed: list[str]) -> str:
661
+ """String form of the sweep outcome, capped but never quietly.
662
+
663
+ Both listings cap at ``_MAX_GREP_HITS`` with an explicit ``+N
664
+ more`` marker. The suppression record rides at the END of the
665
+ string because ``_tail`` keeps the tail — and the authoritative
666
+ record is the structured ``suppressions`` field, not this string.
667
+ """
594
668
  summary = (
595
669
  "no security patterns matched"
596
670
  if not hits
597
671
  else "; ".join(hits[:_MAX_GREP_HITS])
598
672
  )
599
- return CheckResult(
600
- check="security-grep", ran=True, passed=not hits,
601
- command=f"security-grep ({mode}) over {len(changed)} changed file(s)",
602
- exit_code=None, summary=_tail(summary),
603
- )
673
+ if len(hits) > _MAX_GREP_HITS:
674
+ summary += f" (+{len(hits) - _MAX_GREP_HITS} more hits)"
675
+ if suppressed:
676
+ summary += (
677
+ f"; suppressed with arka:sec-ok justification: "
678
+ f"{'; '.join(suppressed[:_MAX_GREP_HITS])}"
679
+ )
680
+ if len(suppressed) > _MAX_GREP_HITS:
681
+ summary += f" (+{len(suppressed) - _MAX_GREP_HITS} more suppressed)"
682
+ return summary
604
683
 
605
684
 
606
685
  def _check_spellcheck(
@@ -0,0 +1,22 @@
1
+ """Harness ownership foundation (workstream C, PR-C1).
2
+
3
+ The harness — the Claude Code configuration that decides what actually
4
+ executes on the operator's machine — has had no owner: the installer
5
+ re-writes the surface only when the operator runs install or update
6
+ (``installer/update.js``), nothing detects or repairs drift between
7
+ those runs, ``harness_scanner`` grades it but nothing closes the loop,
8
+ and the executed desired state lives only inside installer JavaScript.
9
+ This package is the read-only foundation the ``ClaudeConfigManager``
10
+ (C2) builds on:
11
+
12
+ - ``paths`` — canonical locations, resolved at call time
13
+ - ``json_store`` — tolerant reads, atomic writes, ``merge_unique``
14
+ - ``manifest`` — Pydantic schema for ``~/.arkaos/ownership.json``
15
+ - ``spec`` — desired state keyed by runtime (parity-pinned to the
16
+ installer sources by ``test_harness_spec.py``)
17
+ - ``drift`` — spec-vs-disk comparison, never raises
18
+
19
+ Nothing in this package mutates the operator's machine. The only writer
20
+ (``json_store.write_json_atomic``) is a primitive exercised by tests and
21
+ reserved for C2's assert/restore paths.
22
+ """
@@ -0,0 +1,317 @@
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 sys
30
+ from dataclasses import dataclass, field
31
+ from enum import StrEnum
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ from core.harness import json_store, paths
36
+ from core.harness.spec import HookRegistration, RuntimeSpec, spec_for
37
+
38
+
39
+ class DriftStatus(StrEnum):
40
+ """Kind of divergence between spec and disk."""
41
+
42
+ MISSING = "missing"
43
+ DIVERGED = "diverged"
44
+ ADOPTED = "adopted"
45
+ UNREADABLE = "unreadable"
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class DriftFinding:
50
+ """One divergence, with enough detail to act on."""
51
+
52
+ surface: str
53
+ status: DriftStatus
54
+ where: str
55
+ detail: str
56
+
57
+ def to_dict(self) -> dict:
58
+ return {
59
+ "surface": self.surface,
60
+ "status": self.status.value,
61
+ "where": self.where,
62
+ "detail": self.detail,
63
+ }
64
+
65
+
66
+ @dataclass
67
+ class DriftReport:
68
+ """Result of one drift scan against one settings file."""
69
+
70
+ settings_path: Path
71
+ runtime: str
72
+ findings: list[DriftFinding] = field(default_factory=list)
73
+
74
+ @property
75
+ def ok(self) -> bool:
76
+ """True when nothing ArkaOS-owned is missing, diverged or unreadable.
77
+
78
+ ``adopted`` findings do not fail the report — the operator
79
+ winning a seed surface is recorded state, not a defect.
80
+ """
81
+ return not [
82
+ f
83
+ for f in self.findings
84
+ if f.status is not DriftStatus.ADOPTED
85
+ ]
86
+
87
+ def to_dict(self) -> dict:
88
+ return {
89
+ "settings_path": str(self.settings_path),
90
+ "runtime": self.runtime,
91
+ "ok": self.ok,
92
+ "findings": [f.to_dict() for f in self.findings],
93
+ }
94
+
95
+
96
+ def scan(
97
+ home: Path | None = None,
98
+ runtime: str = "claude-code",
99
+ platform: str | None = None,
100
+ hooks_root: str | None = None,
101
+ ) -> DriftReport:
102
+ """Compare the harness on disk to the runtime spec. Never raises.
103
+
104
+ ``platform`` defaults to ``sys.platform`` (``win32`` skips
105
+ posix-only registrations); ``hooks_root`` overrides the ArkaOS root
106
+ used to evaluate ``conditional`` registrations.
107
+ """
108
+ settings_path = paths.claude_settings_path(home)
109
+ report = DriftReport(settings_path=settings_path, runtime=runtime)
110
+ spec = _spec_or_finding(report, runtime)
111
+ if spec is None:
112
+ return report
113
+ try:
114
+ _scan_into(report, spec, platform, hooks_root)
115
+ except Exception as exc: # never-raises boundary
116
+ report.findings.append(
117
+ DriftFinding(
118
+ "settings", DriftStatus.UNREADABLE, str(settings_path),
119
+ f"scan aborted: {type(exc).__name__}: {exc}",
120
+ )
121
+ )
122
+ return report
123
+
124
+
125
+ def _spec_or_finding(report: DriftReport, runtime: str) -> RuntimeSpec | None:
126
+ """Resolve the runtime spec, or record why the scan cannot run.
127
+
128
+ An unknown runtime is a caller problem, not a settings problem —
129
+ naming the settings file would send the operator to debug the
130
+ wrong thing.
131
+ """
132
+ try:
133
+ return spec_for(runtime)
134
+ except ValueError as exc:
135
+ report.findings.append(
136
+ DriftFinding("runtime", DriftStatus.UNREADABLE, runtime, str(exc))
137
+ )
138
+ return None
139
+
140
+
141
+ # Operator-facing phrasing per load error; the installer remediation is
142
+ # only true for the missing case — re-running it does not repair a
143
+ # corrupted or oversized file.
144
+ _LOAD_ERROR_DETAIL = {
145
+ "missing": "settings file missing; run the installer to seed it",
146
+ "invalid-json": "settings file is not valid JSON",
147
+ "not-an-object": "settings file is not a JSON object",
148
+ "unreadable": "settings file could not be read",
149
+ "oversized": "settings file exceeds the 2 MiB read ceiling",
150
+ }
151
+
152
+
153
+ def _scan_into(
154
+ report: DriftReport,
155
+ spec: RuntimeSpec,
156
+ platform: str | None,
157
+ hooks_root: str | None,
158
+ ) -> None:
159
+ loaded = json_store.load_json(report.settings_path)
160
+ if not loaded.ok:
161
+ status = (
162
+ DriftStatus.MISSING
163
+ if loaded.error == "missing"
164
+ else DriftStatus.UNREADABLE
165
+ )
166
+ detail = _LOAD_ERROR_DETAIL.get(
167
+ loaded.error or "", f"settings file unusable: {loaded.error}"
168
+ )
169
+ report.findings.append(
170
+ DriftFinding("settings", status, str(report.settings_path), detail)
171
+ )
172
+ return
173
+ settings = loaded.data or {}
174
+ _check_hooks(report, spec, settings, platform, hooks_root)
175
+ _check_hard_deny(report, spec, settings)
176
+ _check_status_line(report, settings)
177
+ _check_worktree(report, settings)
178
+
179
+
180
+ def _check_hooks(
181
+ report: DriftReport,
182
+ spec: RuntimeSpec,
183
+ settings: dict[str, Any],
184
+ platform: str | None,
185
+ hooks_root: str | None,
186
+ ) -> None:
187
+ hooks = settings.get("hooks")
188
+ hooks = hooks if isinstance(hooks, dict) else {}
189
+ is_windows = (platform or sys.platform) == "win32"
190
+ for reg in spec.hook_registrations:
191
+ if reg.posix_only and is_windows:
192
+ continue
193
+ if reg.conditional and not _script_deployed(reg, hooks_root):
194
+ continue
195
+ _check_registration(report, reg, hooks.get(reg.event))
196
+
197
+
198
+ def _check_registration(
199
+ report: DriftReport, reg: HookRegistration, entries: Any
200
+ ) -> None:
201
+ where = f"hooks.{reg.event}" + (
202
+ f"[matcher={reg.matcher}]" if reg.matcher else ""
203
+ )
204
+ entry = _find_entry(reg, entries)
205
+ if entry is None:
206
+ report.findings.append(
207
+ DriftFinding(
208
+ "settings:hooks", DriftStatus.MISSING, where,
209
+ f"no {reg.script} entry registered for this event",
210
+ )
211
+ )
212
+ return
213
+ timeout = entry.get("timeout")
214
+ 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
+ )
220
+ )
221
+
222
+
223
+ def _find_entry(reg: HookRegistration, entries: Any) -> dict | None:
224
+ """The ArkaOS inner hook entry for ``reg``, or None."""
225
+ if not isinstance(entries, list):
226
+ return None
227
+ wanted = {f"{reg.script}.sh", f"{reg.script}.ps1", f"{reg.script}.cjs"}
228
+ for group in entries:
229
+ if not isinstance(group, dict):
230
+ continue
231
+ if (group.get("matcher") or None) != reg.matcher:
232
+ continue
233
+ for inner in group.get("hooks") or []:
234
+ if not isinstance(inner, dict):
235
+ continue
236
+ command = str(inner.get("command", ""))
237
+ if command and Path(command).name in wanted:
238
+ return inner
239
+ return None
240
+
241
+
242
+ def _script_deployed(reg: HookRegistration, hooks_root: str | None) -> bool:
243
+ try:
244
+ hooks = paths.hooks_dir(hooks_root)
245
+ return any(
246
+ (hooks / f"{reg.script}{ext}").is_file()
247
+ for ext in (".sh", ".ps1", ".cjs")
248
+ )
249
+ except OSError:
250
+ return False
251
+
252
+
253
+ def _check_hard_deny(
254
+ report: DriftReport, spec: RuntimeSpec, settings: dict[str, Any]
255
+ ) -> None:
256
+ auto_mode = settings.get("autoMode")
257
+ auto_mode = auto_mode if isinstance(auto_mode, dict) else {}
258
+ rules = auto_mode.get("hard_deny")
259
+ rules = rules if isinstance(rules, list) else []
260
+ present = {r for r in rules if isinstance(r, str)}
261
+ absent = [r for r in spec.hard_deny_rules if r not in present]
262
+ if not absent:
263
+ return
264
+ examples = ", ".join(absent[:3])
265
+ report.findings.append(
266
+ DriftFinding(
267
+ "settings:autoMode.hard_deny", DriftStatus.MISSING,
268
+ "autoMode.hard_deny",
269
+ f"{len(absent)} of {len(spec.hard_deny_rules)} curated deny "
270
+ f"rules absent (e.g. {examples})",
271
+ )
272
+ )
273
+
274
+
275
+ def _check_status_line(report: DriftReport, settings: dict[str, Any]) -> None:
276
+ status_line = settings.get("statusLine")
277
+ if status_line is None:
278
+ report.findings.append(
279
+ DriftFinding(
280
+ "settings:statusLine", DriftStatus.MISSING, "statusLine",
281
+ "status line not seeded",
282
+ )
283
+ )
284
+ elif not _is_arkaos_statusline(status_line):
285
+ report.findings.append(
286
+ DriftFinding(
287
+ "settings:statusLine", DriftStatus.ADOPTED, "statusLine",
288
+ "operator-configured status line; seed policy adopts it",
289
+ )
290
+ )
291
+
292
+
293
+ def _check_worktree(report: DriftReport, settings: dict[str, Any]) -> None:
294
+ worktree = settings.get("worktree")
295
+ if worktree is None:
296
+ report.findings.append(
297
+ DriftFinding(
298
+ "settings:worktree", DriftStatus.MISSING, "worktree",
299
+ "worktree.baseRef default not seeded",
300
+ )
301
+ )
302
+ elif not (
303
+ isinstance(worktree, dict) and worktree.get("baseRef") == "head"
304
+ ):
305
+ report.findings.append(
306
+ DriftFinding(
307
+ "settings:worktree", DriftStatus.ADOPTED, "worktree",
308
+ "operator-configured worktree; seed policy adopts it",
309
+ )
310
+ )
311
+
312
+
313
+ def _is_arkaos_statusline(status_line: Any) -> bool:
314
+ if not isinstance(status_line, dict):
315
+ return False
316
+ command = str(status_line.get("command", ""))
317
+ 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)