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,577 @@
|
|
|
1
|
+
"""ClaudeConfigManager — the write side of harness ownership (PR-C2).
|
|
2
|
+
|
|
3
|
+
C1 gave the vocabulary (spec → manifest → drift); this module closes
|
|
4
|
+
the loop: ``assert_ownership`` makes the operator's ``settings.json``
|
|
5
|
+
match the runtime spec UNDER THE POLICIES, and nothing else.
|
|
6
|
+
|
|
7
|
+
- ``own-subset`` (hooks, ``autoMode.hard_deny``): ArkaOS entries are
|
|
8
|
+
ensured — created, timeout-repaired, stale-root-repaired — and
|
|
9
|
+
operator entries on the same surface are preserved verbatim.
|
|
10
|
+
- ``seed`` (statusLine, worktree): written only when absent. An
|
|
11
|
+
operator-adopted surface is never reverted by ``assert``; only the
|
|
12
|
+
explicitly named ``restore`` re-seeds it.
|
|
13
|
+
- ``operator``: never touched.
|
|
14
|
+
|
|
15
|
+
REFUSAL IS THE DEFAULT ON ANYTHING UNEXPECTED. A settings file that
|
|
16
|
+
cannot be READ is never overwritten, and an operator value whose TYPE
|
|
17
|
+
is wrong is left exactly as it is with a ``refused`` action on the
|
|
18
|
+
report — coercing either one destroyed configuration silently (QG C2
|
|
19
|
+
r1).
|
|
20
|
+
|
|
21
|
+
Every mutation lands in the audit trail
|
|
22
|
+
(``~/.arkaos/audit/harness-mutations.jsonl``) as surface, action, and
|
|
23
|
+
either the surface digest or a type name. Settings values and env
|
|
24
|
+
values never appear. The manifest (``~/.arkaos/ownership.json``)
|
|
25
|
+
records what was asserted and when, per surface, and leaves surfaces
|
|
26
|
+
this run deliberately did not assert unstamped: the manifest is
|
|
27
|
+
rebuilt from the spec each run, so an earlier timestamp on those
|
|
28
|
+
surfaces is not carried over. Writes go through the C1 atomic writer:
|
|
29
|
+
a crash mid-assert leaves the previous settings intact.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import hashlib
|
|
35
|
+
import json
|
|
36
|
+
import sys
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from datetime import UTC, datetime
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from core.harness import drift, json_store, paths
|
|
42
|
+
from core.harness.manifest import OwnershipManifest
|
|
43
|
+
from core.harness.spec import HookRegistration, spec_for
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class SurfaceAction:
|
|
48
|
+
"""One thing assert did (or deliberately did not do) to a surface."""
|
|
49
|
+
|
|
50
|
+
surface: str
|
|
51
|
+
# created | repaired | unrepaired | reseeded | adopted-skip
|
|
52
|
+
# | refused | noop
|
|
53
|
+
action: str
|
|
54
|
+
detail: str = ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class AssertReport:
|
|
59
|
+
"""Outcome of one assert/restore/harden run."""
|
|
60
|
+
|
|
61
|
+
verb: str
|
|
62
|
+
settings_path: Path
|
|
63
|
+
changed: bool
|
|
64
|
+
actions: list[SurfaceAction] = field(default_factory=list)
|
|
65
|
+
refused: str | None = None # why nothing was written at all
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> dict:
|
|
68
|
+
return {
|
|
69
|
+
"verb": self.verb,
|
|
70
|
+
"settings_path": str(self.settings_path),
|
|
71
|
+
"changed": self.changed,
|
|
72
|
+
"refused": self.refused,
|
|
73
|
+
"actions": [
|
|
74
|
+
{"surface": a.surface, "action": a.action, "detail": a.detail}
|
|
75
|
+
for a in self.actions
|
|
76
|
+
],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ClaudeConfigManager:
|
|
81
|
+
"""Asserts and reports ArkaOS ownership of the Claude Code harness."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
home: Path | None = None,
|
|
86
|
+
runtime: str = "claude-code",
|
|
87
|
+
hooks_root: str | None = None,
|
|
88
|
+
platform: str | None = None,
|
|
89
|
+
):
|
|
90
|
+
self.home = home
|
|
91
|
+
self.runtime = runtime
|
|
92
|
+
self.hooks_root = hooks_root
|
|
93
|
+
self.platform = platform or sys.platform
|
|
94
|
+
self.spec = spec_for(runtime)
|
|
95
|
+
|
|
96
|
+
# ── read side ────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
def status(self) -> dict:
|
|
99
|
+
"""Drift report + manifest state. Read-only by contract."""
|
|
100
|
+
report = drift.scan(
|
|
101
|
+
home=self.home, runtime=self.runtime,
|
|
102
|
+
platform=self.platform, hooks_root=self.hooks_root,
|
|
103
|
+
)
|
|
104
|
+
manifest, error = _load_manifest(self.home)
|
|
105
|
+
return {
|
|
106
|
+
"drift": report.to_dict(),
|
|
107
|
+
"manifest": manifest.model_dump() if manifest else None,
|
|
108
|
+
"manifest_error": error,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# ── write side ───────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
def assert_ownership(
|
|
114
|
+
self, reseed_adopted: bool = False, verb: str | None = None
|
|
115
|
+
) -> AssertReport:
|
|
116
|
+
"""Make settings.json match the spec under the policies."""
|
|
117
|
+
verb = verb or ("restore" if reseed_adopted else "assert")
|
|
118
|
+
settings_path = paths.claude_settings_path(self.home)
|
|
119
|
+
loaded = json_store.load_json(settings_path)
|
|
120
|
+
report = AssertReport(
|
|
121
|
+
verb=verb, settings_path=settings_path, changed=False
|
|
122
|
+
)
|
|
123
|
+
if loaded.error not in (None, "missing"):
|
|
124
|
+
# A settings file we cannot READ is one we must not
|
|
125
|
+
# OVERWRITE: treating it as a fresh install destroyed env,
|
|
126
|
+
# permissions and plugins with no backup (QG C2 r1,
|
|
127
|
+
# Francisca B2). Refusing is the whole contract.
|
|
128
|
+
report.refused = drift._LOAD_ERROR_DETAIL.get(
|
|
129
|
+
loaded.error or "", f"settings file unusable: {loaded.error}"
|
|
130
|
+
)
|
|
131
|
+
return report
|
|
132
|
+
settings = loaded.data or {}
|
|
133
|
+
before = json.dumps(settings, sort_keys=True)
|
|
134
|
+
self._assert_hooks(settings, report)
|
|
135
|
+
self._assert_hard_deny(settings, report)
|
|
136
|
+
self._assert_seed(settings, report, reseed_adopted)
|
|
137
|
+
report.changed = json.dumps(settings, sort_keys=True) != before
|
|
138
|
+
if report.changed:
|
|
139
|
+
json_store.write_json_atomic(settings_path, settings)
|
|
140
|
+
self._record(report, settings)
|
|
141
|
+
return report
|
|
142
|
+
|
|
143
|
+
def restore(self) -> AssertReport:
|
|
144
|
+
"""Assert PLUS re-seeding adopted seed surfaces — the
|
|
145
|
+
explicitly named operator override."""
|
|
146
|
+
return self.assert_ownership(reseed_adopted=True)
|
|
147
|
+
|
|
148
|
+
def harden(self) -> tuple[AssertReport, str]:
|
|
149
|
+
"""Assert, then grade the result with the harness scanner.
|
|
150
|
+
|
|
151
|
+
Returns ``(report, grade)``; callers treat a grade below B (or
|
|
152
|
+
any CRITICAL) as failure — no silent half-hardened state.
|
|
153
|
+
"""
|
|
154
|
+
from core.governance.harness_scanner import scan
|
|
155
|
+
|
|
156
|
+
report = self.assert_ownership(verb="harden")
|
|
157
|
+
scan_report = scan(paths.claude_home(self.home))
|
|
158
|
+
return report, scan_report.grade
|
|
159
|
+
|
|
160
|
+
# ── flags ────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
def read_flags(self) -> dict:
|
|
163
|
+
result = json_store.load_json(paths.arkaos_config_path(self.home))
|
|
164
|
+
hooks = (result.data or {}).get("hooks", {})
|
|
165
|
+
hooks = hooks if isinstance(hooks, dict) else {}
|
|
166
|
+
return {name: hooks.get(name) for name in FLAG_NAMES}
|
|
167
|
+
|
|
168
|
+
def set_flag(self, name: str, value: object) -> dict:
|
|
169
|
+
"""Set one enforcement flag by explicit name AND value.
|
|
170
|
+
|
|
171
|
+
Both are validated: the gates read hardEnforcement and
|
|
172
|
+
specialistEnforcement with ``bool()``, so an unvalidated
|
|
173
|
+
``"warn"`` turned enforcement ON while the operator asked for
|
|
174
|
+
warn (QG C2 r1, Eduardo B2).
|
|
175
|
+
"""
|
|
176
|
+
_validate_flag(name, value)
|
|
177
|
+
config_path = paths.arkaos_config_path(self.home)
|
|
178
|
+
config, hooks = _load_flag_config(config_path)
|
|
179
|
+
hooks[name] = value
|
|
180
|
+
json_store.write_json_atomic(config_path, config)
|
|
181
|
+
_audit_line(
|
|
182
|
+
self.home,
|
|
183
|
+
{"verb": "flags", "surface": f"config:hooks.{name}",
|
|
184
|
+
"action": "set", "detail": type(value).__name__},
|
|
185
|
+
)
|
|
186
|
+
return self.read_flags()
|
|
187
|
+
|
|
188
|
+
# ── internals ────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
def _assert_hooks(self, settings: dict, report: AssertReport) -> None:
|
|
191
|
+
hooks = settings.setdefault("hooks", {})
|
|
192
|
+
if not isinstance(hooks, dict):
|
|
193
|
+
report.actions.append(_refusal("settings:hooks", "hooks", hooks))
|
|
194
|
+
return
|
|
195
|
+
accepted = drift._accepted_hook_dirs(
|
|
196
|
+
report.settings_path, self.hooks_root
|
|
197
|
+
)
|
|
198
|
+
for reg in self.spec.hook_registrations:
|
|
199
|
+
if reg.posix_only and self.platform == "win32":
|
|
200
|
+
continue
|
|
201
|
+
if reg.conditional and not drift._script_deployed(
|
|
202
|
+
reg, self.hooks_root
|
|
203
|
+
):
|
|
204
|
+
continue
|
|
205
|
+
self._ensure_registration(hooks, reg, accepted, report)
|
|
206
|
+
|
|
207
|
+
def _ensure_registration(
|
|
208
|
+
self,
|
|
209
|
+
hooks: dict,
|
|
210
|
+
reg: HookRegistration,
|
|
211
|
+
accepted: frozenset[str] | None,
|
|
212
|
+
report: AssertReport,
|
|
213
|
+
) -> None:
|
|
214
|
+
where = f"hooks.{reg.event}" + (
|
|
215
|
+
f"[matcher={reg.matcher}]" if reg.matcher else ""
|
|
216
|
+
)
|
|
217
|
+
groups = hooks.setdefault(reg.event, [])
|
|
218
|
+
if not isinstance(groups, list):
|
|
219
|
+
report.actions.append(
|
|
220
|
+
_refusal(f"hooks.{reg.event}", reg.event, groups)
|
|
221
|
+
)
|
|
222
|
+
return
|
|
223
|
+
entry = drift._find_entry(reg, groups)
|
|
224
|
+
if entry is None:
|
|
225
|
+
groups.append(self._group_for(reg))
|
|
226
|
+
report.actions.append(SurfaceAction(where, "created"))
|
|
227
|
+
return
|
|
228
|
+
repaired = self._repair_entry(entry, reg, accepted)
|
|
229
|
+
report.actions.append(
|
|
230
|
+
SurfaceAction(where, _repair_action(repaired), ",".join(repaired))
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def _repair_entry(
|
|
234
|
+
self,
|
|
235
|
+
entry: dict,
|
|
236
|
+
reg: HookRegistration,
|
|
237
|
+
accepted: frozenset[str] | None,
|
|
238
|
+
) -> list[str]:
|
|
239
|
+
"""Repairs the divergences drift reports, keyed by the same
|
|
240
|
+
vocabulary as ``drift.entry_divergences``.
|
|
241
|
+
|
|
242
|
+
Only timeout and stale-root are repairable today; a new
|
|
243
|
+
divergence kind is recorded as ``unrepairable:<kind>`` rather
|
|
244
|
+
than silently read as a noop.
|
|
245
|
+
"""
|
|
246
|
+
repaired = []
|
|
247
|
+
for divergence in drift.entry_divergences(entry, reg, accepted):
|
|
248
|
+
if divergence.startswith("timeout"):
|
|
249
|
+
entry["timeout"] = reg.timeout
|
|
250
|
+
repaired.append("timeout")
|
|
251
|
+
elif divergence.startswith("stale-root"):
|
|
252
|
+
entry["command"] = self._command_for(
|
|
253
|
+
reg, entry.get("command")
|
|
254
|
+
)
|
|
255
|
+
repaired.append("stale-root")
|
|
256
|
+
else:
|
|
257
|
+
repaired.append(f"unrepairable:{_divergence_kind(divergence)}")
|
|
258
|
+
return repaired
|
|
259
|
+
|
|
260
|
+
def _group_for(self, reg: HookRegistration) -> dict:
|
|
261
|
+
inner = {
|
|
262
|
+
"type": "command",
|
|
263
|
+
"command": self._command_for(reg),
|
|
264
|
+
"timeout": reg.timeout,
|
|
265
|
+
}
|
|
266
|
+
if self.platform == "win32":
|
|
267
|
+
inner["shell"] = "powershell"
|
|
268
|
+
group: dict = {"hooks": [inner]}
|
|
269
|
+
if reg.matcher:
|
|
270
|
+
group["matcher"] = reg.matcher
|
|
271
|
+
return group
|
|
272
|
+
|
|
273
|
+
def _command_for(self, reg: HookRegistration, current: object = "") -> str:
|
|
274
|
+
"""Path to ``reg``'s script in the INSTALLED hooks dir.
|
|
275
|
+
|
|
276
|
+
The suffix of an existing entry wins when there is one:
|
|
277
|
+
rewriting a deployed ``.cjs`` to ``.sh`` silently dropped the
|
|
278
|
+
Node fastpath (QG C2 r1 B1).
|
|
279
|
+
"""
|
|
280
|
+
existing = drift.hook_command_path(current).suffix
|
|
281
|
+
default = ".ps1" if self.platform == "win32" else ".sh"
|
|
282
|
+
ext = existing if existing in (".sh", ".ps1", ".cjs") else default
|
|
283
|
+
return str(self._hooks_dir() / f"{reg.script}{ext}")
|
|
284
|
+
|
|
285
|
+
def _hooks_dir(self) -> Path:
|
|
286
|
+
"""Where a written command must point.
|
|
287
|
+
|
|
288
|
+
The INSTALLED tree (``~/.arkaos/config/hooks``) whenever it
|
|
289
|
+
holds the deployment, because the resolved root is routinely an
|
|
290
|
+
npx cache that ``npm cache clean`` purges — pointing entries at
|
|
291
|
+
the resolved root left every created and repaired command
|
|
292
|
+
naming a directory that can vanish (QG C2 r2, Francisca B1).
|
|
293
|
+
The resolved root is the fallback for a source checkout with
|
|
294
|
+
no install.
|
|
295
|
+
"""
|
|
296
|
+
installed = paths.arkaos_home(self.home) / "config" / "hooks"
|
|
297
|
+
try:
|
|
298
|
+
if installed.is_dir():
|
|
299
|
+
return installed
|
|
300
|
+
except OSError:
|
|
301
|
+
pass
|
|
302
|
+
return paths.hooks_dir(self.hooks_root)
|
|
303
|
+
|
|
304
|
+
def _assert_hard_deny(self, settings: dict, report: AssertReport) -> None:
|
|
305
|
+
auto_mode = settings.setdefault("autoMode", {})
|
|
306
|
+
if not isinstance(auto_mode, dict):
|
|
307
|
+
report.actions.append(
|
|
308
|
+
_refusal("settings:autoMode.hard_deny", "autoMode", auto_mode)
|
|
309
|
+
)
|
|
310
|
+
return
|
|
311
|
+
existing = auto_mode.get("hard_deny")
|
|
312
|
+
if existing is not None and not isinstance(existing, list):
|
|
313
|
+
report.actions.append(
|
|
314
|
+
_refusal(
|
|
315
|
+
"settings:autoMode.hard_deny", "hard_deny", existing
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
return
|
|
319
|
+
existing = existing or []
|
|
320
|
+
merged = self._merged_deny_rules(existing)
|
|
321
|
+
if merged != existing:
|
|
322
|
+
auto_mode["hard_deny"] = merged
|
|
323
|
+
report.actions.append(
|
|
324
|
+
SurfaceAction(
|
|
325
|
+
"settings:autoMode.hard_deny", "repaired",
|
|
326
|
+
f"{len(merged) - len(existing)} rule(s) added",
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
else:
|
|
330
|
+
report.actions.append(
|
|
331
|
+
SurfaceAction("settings:autoMode.hard_deny", "noop")
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def _merged_deny_rules(self, existing: list) -> list:
|
|
335
|
+
"""Spec rules + operator extensions merged into ``existing``.
|
|
336
|
+
|
|
337
|
+
Non-string members are excluded from the MERGE and appended
|
|
338
|
+
back untouched: ``merge_unique`` hashes its inputs, so a dict
|
|
339
|
+
member raised TypeError out of a never-raises path (QG C2 r1,
|
|
340
|
+
Francisca B3), and dropping it would delete operator data.
|
|
341
|
+
"""
|
|
342
|
+
strings = [r for r in existing if isinstance(r, str)]
|
|
343
|
+
others = [r for r in existing if not isinstance(r, str)]
|
|
344
|
+
# Operator entries first — first occurrence wins, so operator
|
|
345
|
+
# order is preserved (the installer mergeUnique contract).
|
|
346
|
+
merged = json_store.merge_unique(
|
|
347
|
+
strings, list(self.spec.hard_deny_rules),
|
|
348
|
+
_user_deny_extensions(self.home),
|
|
349
|
+
)
|
|
350
|
+
return merged + others if others else merged
|
|
351
|
+
|
|
352
|
+
def _assert_seed(
|
|
353
|
+
self, settings: dict, report: AssertReport, reseed: bool
|
|
354
|
+
) -> None:
|
|
355
|
+
for surface, key, default, is_ours in _seed_surfaces(self):
|
|
356
|
+
current = settings.get(key)
|
|
357
|
+
if current is None:
|
|
358
|
+
settings[key] = default
|
|
359
|
+
report.actions.append(SurfaceAction(surface, "created"))
|
|
360
|
+
elif is_ours(current):
|
|
361
|
+
report.actions.append(SurfaceAction(surface, "noop"))
|
|
362
|
+
elif reseed:
|
|
363
|
+
settings[key] = default
|
|
364
|
+
report.actions.append(SurfaceAction(surface, "reseeded"))
|
|
365
|
+
else:
|
|
366
|
+
report.actions.append(
|
|
367
|
+
SurfaceAction(
|
|
368
|
+
surface, "adopted-skip",
|
|
369
|
+
"operator-configured; assert never reverts seed",
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
def _record(self, report: AssertReport, settings: dict) -> None:
|
|
374
|
+
stamp = datetime.now(UTC).isoformat()
|
|
375
|
+
skipped = {
|
|
376
|
+
a.surface for a in report.actions
|
|
377
|
+
if a.action in ("adopted-skip", "refused")
|
|
378
|
+
}
|
|
379
|
+
manifest = OwnershipManifest.default(self.runtime)
|
|
380
|
+
for record in manifest.surfaces:
|
|
381
|
+
# A surface ArkaOS deliberately did NOT assert must not be
|
|
382
|
+
# stamped as asserted — the manifest contradicted the audit
|
|
383
|
+
# trail for adopted seeds (QG C2 r1, Eduardo B3).
|
|
384
|
+
if record.surface in skipped:
|
|
385
|
+
continue
|
|
386
|
+
record.last_asserted = stamp
|
|
387
|
+
record.content_sha256 = _surface_digest(settings, record.surface)
|
|
388
|
+
json_store.write_json_atomic(
|
|
389
|
+
paths.ownership_manifest_path(self.home), manifest.model_dump()
|
|
390
|
+
)
|
|
391
|
+
for action in report.actions:
|
|
392
|
+
if action.action in ("noop", "adopted-skip"):
|
|
393
|
+
continue
|
|
394
|
+
_audit_line(
|
|
395
|
+
self.home,
|
|
396
|
+
{"verb": report.verb, "surface": action.surface,
|
|
397
|
+
"action": action.action, "detail": action.detail,
|
|
398
|
+
"content_sha256": _surface_digest(settings, action.surface)},
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
FLAG_NAMES: tuple[str, ...] = (
|
|
403
|
+
"hardEnforcement", "frontendGate", "specialistEnforcement",
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# Each flag's OWN vocabulary. hardEnforcement and specialistEnforcement
|
|
407
|
+
# are read with bool() by their gates, so a stray string like "warn"
|
|
408
|
+
# silently means ON — the operator asked for warn and got hard (QG C2
|
|
409
|
+
# r1, Eduardo B2). frontendGate is the tri-state one.
|
|
410
|
+
FLAG_VALUES: dict[str, tuple] = {
|
|
411
|
+
"hardEnforcement": (True, False),
|
|
412
|
+
"specialistEnforcement": (True, False),
|
|
413
|
+
"frontendGate": ("off", "warn", "hard"),
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _divergence_kind(divergence: str) -> str:
|
|
418
|
+
"""The KIND word of a divergence drift reports.
|
|
419
|
+
|
|
420
|
+
First word only: a divergence written without a ``kind: detail``
|
|
421
|
+
colon would otherwise copy its whole message — which can embed an
|
|
422
|
+
operator value — into the audit detail (QG C2 r3, Eduardo).
|
|
423
|
+
"""
|
|
424
|
+
return divergence.split(":")[0].split()[0]
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _repair_action(repaired: list[str]) -> str:
|
|
428
|
+
"""``repaired`` only when something actually was.
|
|
429
|
+
|
|
430
|
+
An entry whose every divergence is unhandled was labelled
|
|
431
|
+
``repaired`` with detail ``unrepairable:<kind>`` — the CLI printed
|
|
432
|
+
"repaired: hooks.Stop (unrepairable:shell)" and the audit trail
|
|
433
|
+
said repaired for a surface nothing repaired. That is the
|
|
434
|
+
silent-success pattern this module exists to kill (QG C2 r3,
|
|
435
|
+
Eduardo). A MIXED entry keeps ``repaired`` and carries the
|
|
436
|
+
unrepairable kind in detail.
|
|
437
|
+
"""
|
|
438
|
+
if not repaired:
|
|
439
|
+
return "noop"
|
|
440
|
+
if all(item.startswith("unrepairable:") for item in repaired):
|
|
441
|
+
return "unrepaired"
|
|
442
|
+
return "repaired"
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _validate_flag(name: str, value: object) -> None:
|
|
446
|
+
if name not in FLAG_NAMES:
|
|
447
|
+
raise ValueError(
|
|
448
|
+
f"unknown flag {name!r}; known: {', '.join(FLAG_NAMES)}"
|
|
449
|
+
)
|
|
450
|
+
allowed = FLAG_VALUES[name]
|
|
451
|
+
if value not in allowed:
|
|
452
|
+
raise ValueError(
|
|
453
|
+
f"invalid value {value!r} for {name}; allowed: "
|
|
454
|
+
f"{', '.join(str(a).lower() for a in allowed)}"
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _load_flag_config(config_path: Path) -> tuple[dict, dict]:
|
|
459
|
+
"""``(config, hooks)`` — refuses rather than replacing either."""
|
|
460
|
+
result = json_store.load_json(config_path)
|
|
461
|
+
if result.error not in (None, "missing"):
|
|
462
|
+
raise ValueError(
|
|
463
|
+
f"~/.arkaos/config.json is {result.error} — refusing to "
|
|
464
|
+
f"overwrite it"
|
|
465
|
+
)
|
|
466
|
+
config = result.data or {}
|
|
467
|
+
hooks = config.get("hooks")
|
|
468
|
+
if hooks is None:
|
|
469
|
+
hooks = config["hooks"] = {}
|
|
470
|
+
elif not isinstance(hooks, dict):
|
|
471
|
+
raise ValueError(
|
|
472
|
+
f"config.hooks is {type(hooks).__name__}, expected object — "
|
|
473
|
+
f"refusing to replace it"
|
|
474
|
+
)
|
|
475
|
+
return config, hooks
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _surface_digest(settings: dict, surface: str) -> str:
|
|
479
|
+
"""Digest of the content on ``surface``.
|
|
480
|
+
|
|
481
|
+
Per-surface by construction: a whole-file digest moved whenever the
|
|
482
|
+
operator touched anything at all (QG C2 r1, Francisca B5 / Eduardo
|
|
483
|
+
B3). On own-subset surfaces the digest still covers the operator
|
|
484
|
+
entries preserved alongside the ArkaOS ones — that is the surface,
|
|
485
|
+
and separating them is not something this field claims to do.
|
|
486
|
+
"""
|
|
487
|
+
owned: object
|
|
488
|
+
if surface == "settings:hooks":
|
|
489
|
+
owned = settings.get("hooks")
|
|
490
|
+
elif surface == "settings:autoMode.hard_deny":
|
|
491
|
+
auto = settings.get("autoMode")
|
|
492
|
+
owned = auto.get("hard_deny") if isinstance(auto, dict) else None
|
|
493
|
+
elif surface.startswith("hooks."):
|
|
494
|
+
owned = (settings.get("hooks") or {}).get(
|
|
495
|
+
surface.split("[", 1)[0].removeprefix("hooks.")
|
|
496
|
+
)
|
|
497
|
+
else:
|
|
498
|
+
owned = settings.get(surface.removeprefix("settings:"))
|
|
499
|
+
return hashlib.sha256(
|
|
500
|
+
json.dumps(owned, sort_keys=True, default=str).encode("utf-8")
|
|
501
|
+
).hexdigest()
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _refusal(surface: str, key: str, value: object) -> SurfaceAction:
|
|
505
|
+
"""Recorded when an operator value has an unexpected TYPE.
|
|
506
|
+
|
|
507
|
+
Coercing it to an empty container deleted operator hooks and deny
|
|
508
|
+
rules silently (QG C2 r1, Francisca B4). A type mismatch is a
|
|
509
|
+
refusal to touch the surface, never a repair.
|
|
510
|
+
"""
|
|
511
|
+
return SurfaceAction(
|
|
512
|
+
surface, "refused",
|
|
513
|
+
f"{key} is {type(value).__name__}, expected "
|
|
514
|
+
f"{'list' if key not in ('hooks', 'autoMode') else 'object'} — "
|
|
515
|
+
f"left untouched",
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _load_manifest(home: Path | None):
|
|
520
|
+
from core.harness.manifest import load_manifest
|
|
521
|
+
|
|
522
|
+
return load_manifest(paths.ownership_manifest_path(home))
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _user_deny_extensions(home: Path | None) -> list[str]:
|
|
526
|
+
result = json_store.load_json(paths.hard_deny_extension_path(home))
|
|
527
|
+
raw = (result.data or {}).get("hard_deny", [])
|
|
528
|
+
if not isinstance(raw, list):
|
|
529
|
+
return []
|
|
530
|
+
return [r for r in raw if isinstance(r, str) and r]
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _seed_surfaces(manager: ClaudeConfigManager):
|
|
534
|
+
statusline = _statusline_default(manager)
|
|
535
|
+
return (
|
|
536
|
+
(
|
|
537
|
+
"settings:statusLine", "statusLine", statusline,
|
|
538
|
+
drift._is_arkaos_statusline,
|
|
539
|
+
),
|
|
540
|
+
(
|
|
541
|
+
"settings:worktree", "worktree", {"baseRef": "head"},
|
|
542
|
+
lambda v: isinstance(v, dict) and v.get("baseRef") == "head",
|
|
543
|
+
),
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _statusline_default(manager: ClaudeConfigManager) -> dict:
|
|
548
|
+
name = "statusline.ps1" if manager.platform == "win32" else "statusline.sh"
|
|
549
|
+
script = manager._hooks_dir().parent / name
|
|
550
|
+
command = (
|
|
551
|
+
f'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass '
|
|
552
|
+
f'-File "{script}"'
|
|
553
|
+
if manager.platform == "win32"
|
|
554
|
+
else str(script)
|
|
555
|
+
)
|
|
556
|
+
return {"type": "command", "command": command, "padding": 2}
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _audit_line(home: Path | None, entry: dict) -> None:
|
|
560
|
+
"""Append one mutation line: surface, action, and either the
|
|
561
|
+
owned-content digest or a type name. Settings values and env values
|
|
562
|
+
never appear.
|
|
563
|
+
|
|
564
|
+
Best-effort by design: the mutation itself already happened
|
|
565
|
+
atomically, and a failed audit write must not corrupt or roll back
|
|
566
|
+
the settings — unlike egress, nothing leaves the machine here.
|
|
567
|
+
"""
|
|
568
|
+
path = paths.harness_audit_log_path(home)
|
|
569
|
+
line = json.dumps(
|
|
570
|
+
{"ts": datetime.now(UTC).isoformat(), **entry}, sort_keys=True
|
|
571
|
+
)
|
|
572
|
+
try:
|
|
573
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
574
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
575
|
+
handle.write(line + "\n")
|
|
576
|
+
except OSError:
|
|
577
|
+
pass
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Ownership manifest schema for ``~/.arkaos/ownership.json`` (PR-C1).
|
|
2
|
+
|
|
3
|
+
The manifest is the C2 manager's record of WHICH harness surfaces
|
|
4
|
+
ArkaOS claims and UNDER WHICH policy. C1 ships only the schema and a
|
|
5
|
+
tolerant loader — nothing here writes the file, and the default
|
|
6
|
+
manifest is DERIVED from ``core.harness.spec`` so the two can never
|
|
7
|
+
disagree about which surfaces exist.
|
|
8
|
+
|
|
9
|
+
Policy vocabulary (decided in the 2026-07-26 campaign plan, C2):
|
|
10
|
+
|
|
11
|
+
- ``own`` — ArkaOS replaces the surface wholesale on assert.
|
|
12
|
+
- ``own-subset`` — ArkaOS guarantees its own entries exist; operator
|
|
13
|
+
entries on the same surface are never removed.
|
|
14
|
+
- ``seed`` — written only when absent; an operator edit is
|
|
15
|
+
ADOPTED (drift reports it, assert never reverts it).
|
|
16
|
+
- ``operator`` — ArkaOS never touches the surface.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from enum import StrEnum
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
25
|
+
|
|
26
|
+
from core.harness import json_store
|
|
27
|
+
from core.harness import spec as harness_spec
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OwnershipPolicy(StrEnum):
|
|
31
|
+
"""How ArkaOS treats one harness surface."""
|
|
32
|
+
|
|
33
|
+
OWN = "own"
|
|
34
|
+
OWN_SUBSET = "own-subset"
|
|
35
|
+
SEED = "seed"
|
|
36
|
+
OPERATOR = "operator"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SurfaceRecord(BaseModel):
|
|
40
|
+
"""One claimed surface and the policy it is held under."""
|
|
41
|
+
|
|
42
|
+
surface: str = Field(description="Stable surface id, e.g. settings:hooks")
|
|
43
|
+
policy: OwnershipPolicy
|
|
44
|
+
last_asserted: str | None = Field(
|
|
45
|
+
default=None,
|
|
46
|
+
description="ISO-8601 timestamp of the last C2 assert; C1 never sets it",
|
|
47
|
+
)
|
|
48
|
+
content_sha256: str | None = Field(
|
|
49
|
+
default=None,
|
|
50
|
+
description=(
|
|
51
|
+
"Digest of the surface content at last assert; on "
|
|
52
|
+
"own-subset surfaces this includes the operator entries "
|
|
53
|
+
"preserved alongside the ArkaOS ones"
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class OwnershipManifest(BaseModel):
|
|
59
|
+
"""The full ownership manifest for one runtime."""
|
|
60
|
+
|
|
61
|
+
version: int = 1
|
|
62
|
+
runtime: str = "claude-code"
|
|
63
|
+
surfaces: list[SurfaceRecord] = Field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def default(cls, runtime: str = "claude-code") -> OwnershipManifest:
|
|
67
|
+
"""Manifest derived from the runtime spec's surface table."""
|
|
68
|
+
runtime_spec = harness_spec.spec_for(runtime)
|
|
69
|
+
return cls(
|
|
70
|
+
runtime=runtime,
|
|
71
|
+
surfaces=[
|
|
72
|
+
SurfaceRecord(surface=s.surface, policy=OwnershipPolicy(s.policy))
|
|
73
|
+
for s in runtime_spec.surfaces
|
|
74
|
+
],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_manifest(path: Path) -> tuple[OwnershipManifest | None, str | None]:
|
|
79
|
+
"""Tolerant manifest load: ``(manifest, None)`` or ``(None, error)``.
|
|
80
|
+
|
|
81
|
+
Error vocabulary extends ``json_store.LoadResult`` with
|
|
82
|
+
``invalid-schema`` — a file that parses as JSON but does not
|
|
83
|
+
validate is reported, never raised.
|
|
84
|
+
"""
|
|
85
|
+
result = json_store.load_json(path)
|
|
86
|
+
if not result.ok:
|
|
87
|
+
return None, result.error
|
|
88
|
+
try:
|
|
89
|
+
return OwnershipManifest.model_validate(result.data), None
|
|
90
|
+
except ValidationError:
|
|
91
|
+
return None, "invalid-schema"
|