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
@@ -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,143 @@
1
+ """CLI for the harness manager — ``npx arkaos harness <verb>``.
2
+
3
+ Verbs: ``status`` (read-only), ``assert`` (apply policies), ``restore``
4
+ (assert + re-seed adopted surfaces), ``harden`` (assert + scanner
5
+ grade, nonzero exit below B), ``flags`` (read, or set with an explicit
6
+ ``--set name=value``). Every verb but ``flags`` takes ``--json``.
7
+ Exit codes are the contract the Node wrapper propagates verbatim.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+
16
+ from core.harness.manager import ClaudeConfigManager
17
+
18
+
19
+ def main(argv: list[str] | None = None) -> int:
20
+ args = _parser().parse_args(argv)
21
+ handler = {
22
+ "status": _status,
23
+ "assert": _assert,
24
+ "restore": _restore,
25
+ "harden": _harden,
26
+ "flags": _flags,
27
+ }[args.verb]
28
+ try:
29
+ return handler(ClaudeConfigManager(), args)
30
+ except Exception as exc: # operators get a message, never a traceback
31
+ print(f"harness {args.verb} failed: {exc}", file=sys.stderr)
32
+ return 1
33
+
34
+
35
+ def _parser() -> argparse.ArgumentParser:
36
+ parser = argparse.ArgumentParser(
37
+ prog="python -m core.harness.cli",
38
+ description="Assert and report ArkaOS ownership of the harness.",
39
+ )
40
+ sub = parser.add_subparsers(dest="verb", required=True)
41
+ for verb in ("status", "assert", "restore", "harden"):
42
+ sub.add_parser(verb).add_argument(
43
+ "--json", action="store_true", dest="as_json"
44
+ )
45
+ flags = sub.add_parser("flags")
46
+ flags.add_argument(
47
+ "--set", dest="assignment", default=None,
48
+ help=(
49
+ "hardEnforcement|specialistEnforcement: true|false; "
50
+ "frontendGate: off|warn|hard; read-only without it"
51
+ ),
52
+ )
53
+ return parser
54
+
55
+
56
+ def _status(manager: ClaudeConfigManager, args) -> int:
57
+ report = manager.status()
58
+ if getattr(args, "as_json", False):
59
+ print(json.dumps(report, indent=2))
60
+ return 0
61
+ _print_drift(report["drift"])
62
+ return 0
63
+
64
+
65
+ def _print_drift(drift_report: dict) -> None:
66
+ print(f"settings: {drift_report['settings_path']}")
67
+ print(f"ok: {drift_report['ok']}")
68
+ for finding in drift_report["findings"]:
69
+ print(
70
+ f" [{finding['status']}] {finding['where']} — "
71
+ f"{finding['detail']}"
72
+ )
73
+ if not drift_report["findings"]:
74
+ print(" no drift")
75
+
76
+
77
+ def _assert(manager: ClaudeConfigManager, args) -> int:
78
+ return _print_report(manager.assert_ownership(), args)
79
+
80
+
81
+ def _restore(manager: ClaudeConfigManager, args) -> int:
82
+ return _print_report(manager.restore(), args)
83
+
84
+
85
+ def _harden(manager: ClaudeConfigManager, args) -> int:
86
+ report, grade = manager.harden()
87
+ code = _print_report(report, args, extra={"scan_grade": grade})
88
+ if not getattr(args, "as_json", False):
89
+ print(f"post-assert scan grade: {grade}")
90
+ if code:
91
+ return code
92
+ return 0 if grade in ("A", "B") else 2
93
+
94
+
95
+ def _flags(manager: ClaudeConfigManager, args) -> int:
96
+ if args.assignment:
97
+ name, separator, raw = args.assignment.partition("=")
98
+ if not separator or not raw.strip():
99
+ print("flags --set expects name=value", file=sys.stderr)
100
+ return 1
101
+ try:
102
+ flags = manager.set_flag(name.strip(), _parse_value(raw.strip()))
103
+ except ValueError as exc:
104
+ print(str(exc), file=sys.stderr)
105
+ return 1
106
+ else:
107
+ flags = manager.read_flags()
108
+ print(json.dumps(flags, indent=2))
109
+ return 0
110
+
111
+
112
+ def _parse_value(raw: str) -> object:
113
+ if raw.lower() in ("true", "false"):
114
+ return raw.lower() == "true"
115
+ return raw.lower()
116
+
117
+
118
+ def _print_report(report, args=None, extra: dict | None = None) -> int:
119
+ if getattr(args, "as_json", False):
120
+ print(json.dumps({**report.to_dict(), **(extra or {})}, indent=2))
121
+ return 1 if report.refused or _has_refusal(report) else 0
122
+ if report.refused:
123
+ print(f"{report.verb}: refused — {report.refused}", file=sys.stderr)
124
+ print(
125
+ " nothing was written; fix or remove the file and re-run",
126
+ file=sys.stderr,
127
+ )
128
+ return 1
129
+ print(f"{report.verb}: changed={report.changed}")
130
+ for action in report.actions:
131
+ if action.action == "noop":
132
+ continue
133
+ detail = f" ({action.detail})" if action.detail else ""
134
+ print(f" {action.action}: {action.surface}{detail}")
135
+ return 1 if _has_refusal(report) else 0
136
+
137
+
138
+ def _has_refusal(report) -> bool:
139
+ return any(a.action == "refused" for a in report.actions)
140
+
141
+
142
+ if __name__ == "__main__":
143
+ raise SystemExit(main())