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.
- 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/drift.py +317 -0
- package/core/harness/json_store.py +119 -0
- package/core/harness/manifest.py +87 -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/knowledge/skills-manifest.json +1 -1
- package/package.json +1 -1
- 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
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
547
|
-
|
|
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
|
-
|
|
550
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
|
633
|
+
hits, suppressed = [], []
|
|
582
634
|
mode = "added-lines" if base else "whole-file"
|
|
583
635
|
for name in changed:
|
|
584
|
-
path =
|
|
585
|
-
if
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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)
|