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
|
@@ -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,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())
|