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,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"