arkaos 4.16.0 → 4.17.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.
@@ -0,0 +1,131 @@
1
+ """`npx arkaos shield` — the operator-facing surface of the harness scanner.
2
+
3
+ Two roots matter and both are scanned by default: the user config
4
+ (``~/.claude``) and the project the operator is standing in. Splitting
5
+ them would be a footgun — the MCP servers that run with the agent's
6
+ permissions usually live in the project's ``.mcp.json``, while the
7
+ permissions themselves live in the user settings. A tool that reports a
8
+ clean bill of health because it only looked at one of them is worse than
9
+ no tool.
10
+
11
+ Exit codes: 0 = grade A/B, 1 = grade C/D, 2 = grade F or a CRITICAL
12
+ finding. CI can gate on it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from core.governance.harness_scanner import (
23
+ PENALTY,
24
+ Finding,
25
+ ScanReport,
26
+ Severity,
27
+ scan,
28
+ )
29
+
30
+ _ICON = {
31
+ Severity.CRITICAL: "✗",
32
+ Severity.HIGH: "✗",
33
+ Severity.MEDIUM: "⚠",
34
+ Severity.LOW: "·",
35
+ }
36
+
37
+
38
+ def merge(reports: list[ScanReport]) -> ScanReport:
39
+ """One grade for the operator, not one per file they must add up."""
40
+ merged = ScanReport(root=reports[0].root if reports else Path("."))
41
+ for report in reports:
42
+ merged.files_scanned += report.files_scanned
43
+ for finding in report.findings:
44
+ merged.findings.append(_qualify(finding, report.root))
45
+ merged.findings.sort(key=lambda f: (-PENALTY[f.severity], f.rule))
46
+ return merged
47
+
48
+
49
+ def _qualify(finding: Finding, root: Path) -> Finding:
50
+ """Name the root in `where` so two scans cannot be confused."""
51
+ return Finding(
52
+ rule=finding.rule,
53
+ severity=finding.severity,
54
+ where=f"{root}/{finding.where}",
55
+ detail=finding.detail,
56
+ fix=finding.fix,
57
+ )
58
+
59
+
60
+ def _plural(count: int, noun: str) -> str:
61
+ return f"{count} {noun}" if count == 1 else f"{count} {noun}s"
62
+
63
+
64
+ def render(report: ScanReport) -> str:
65
+ scanned = _plural(report.files_scanned, "config file")
66
+ lines = ["", f" ArkaOS Shield — {scanned} scanned", ""]
67
+ if not report.findings:
68
+ lines += [f" Grade {report.grade} ({report.score}/100) — nothing "
69
+ f"to report.", ""]
70
+ return "\n".join(lines)
71
+ for severity in Severity:
72
+ found = report.by_severity(severity)
73
+ if not found:
74
+ continue
75
+ lines.append(f" {severity.value.upper()}")
76
+ for finding in found:
77
+ lines.append(f" {_ICON[severity]} {finding.rule} "
78
+ f"({finding.where})")
79
+ lines.append(f" {finding.detail}")
80
+ lines.append(f" fix: {finding.fix}")
81
+ lines.append("")
82
+ summary = _plural(len(report.findings), "finding")
83
+ lines += [f" Grade {report.grade} ({report.score}/100) — {summary}.", ""]
84
+ return "\n".join(lines)
85
+
86
+
87
+ def exit_code(report: ScanReport) -> int:
88
+ # grade already collapses to F on any CRITICAL, so the letter is the
89
+ # single source of the exit contract.
90
+ if report.grade == "F":
91
+ return 2
92
+ return 1 if report.grade in ("C", "D") else 0
93
+
94
+
95
+ def _roots(args: argparse.Namespace) -> list[Path]:
96
+ if args.path:
97
+ return [Path(p).expanduser() for p in args.path]
98
+ return [Path.home() / ".claude", Path.cwd()]
99
+
100
+
101
+ def main(argv: list[str] | None = None) -> int:
102
+ parser = argparse.ArgumentParser(
103
+ prog="arkaos shield",
104
+ description=(
105
+ "Scan the agent harness configuration for vulnerabilities: "
106
+ "over-permissive allow rules, secrets in config, hook command "
107
+ "injection, unpinned MCP servers, prompt injection in "
108
+ "instruction files."
109
+ ),
110
+ epilog="Exit: 0 = A/B, 1 = C/D, 2 = F or any CRITICAL finding.",
111
+ )
112
+ parser.add_argument(
113
+ "path", nargs="*",
114
+ help="Config roots to scan. Default: ~/.claude and the cwd.",
115
+ )
116
+ parser.add_argument(
117
+ "--json", action="store_true", dest="as_json",
118
+ help="Machine-readable output.",
119
+ )
120
+ args = parser.parse_args(argv)
121
+
122
+ report = merge([scan(root) for root in _roots(args) if root.is_dir()])
123
+ if args.as_json:
124
+ print(json.dumps(report.to_dict(), indent=2))
125
+ else:
126
+ print(render(report))
127
+ return exit_code(report)
128
+
129
+
130
+ if __name__ == "__main__":
131
+ sys.exit(main())
@@ -0,0 +1,21 @@
1
+ """Skill-layer models and validators (provenance, schema)."""
2
+
3
+ from core.skills.provenance import (
4
+ FIRST_PARTY,
5
+ METADATA_FIELDS,
6
+ ProvenanceError,
7
+ SkillProvenance,
8
+ declares_provenance,
9
+ parse_provenance,
10
+ provenance_issues,
11
+ )
12
+
13
+ __all__ = [
14
+ "FIRST_PARTY",
15
+ "METADATA_FIELDS",
16
+ "ProvenanceError",
17
+ "SkillProvenance",
18
+ "declares_provenance",
19
+ "parse_provenance",
20
+ "provenance_issues",
21
+ ]
@@ -0,0 +1,195 @@
1
+ """Skill provenance — supply-chain lineage for every SKILL.md.
2
+
3
+ A skill either originates in ArkaOS or it is derived from someone
4
+ else's work. Third-party lineage is a security property, not trivia: a
5
+ skill tree with no origin field cannot be audited after the fact, and
6
+ "which licence is this under?" becomes unanswerable the moment the
7
+ author who ported it moves on.
8
+
9
+ Contract (opt-in, absence means first-party):
10
+
11
+ ---
12
+ name: dev/silent-failure-hunter
13
+ description: ...
14
+ allowed-tools: [Read, Grep, Glob]
15
+ metadata:
16
+ origin: ecc-derived
17
+ source: https://github.com/affaan-m/ecc
18
+ license: MIT
19
+ ---
20
+
21
+ ``origin`` is a lowercase slug (``^[a-z][a-z0-9-]*$``). ``arkaos`` — or
22
+ no metadata block at all — is first-party and needs nothing else. ANY
23
+ other origin MUST declare ``source`` (an http(s) URL) and ``license``.
24
+
25
+ **Fail closed.** A block that is present but unusable — unparseable
26
+ YAML, a tab-indented body, ``metadata`` bound to a scalar, a misspelt
27
+ key (``metadatas:``, ``orgin:``) — is an ERROR, never a silent fall
28
+ back to first-party. Laundering a derived skill into ``arkaos`` by
29
+ typo is the one failure this module exists to prevent.
30
+
31
+ No parser can see the last vector: a port that simply never writes the
32
+ block reads as first-party, and a typo net will always have a hole
33
+ (``metdata:``, ``provenance:``, ``metadata: {}``). Omission is closed
34
+ somewhere else — ``config/skills-provenance.yaml`` classifies every
35
+ skill in the tree as baseline, derived, or self-declaring, and
36
+ ``tests/python/test_skill_provenance.py`` fails CI on any skill that is
37
+ none of the three. A new skill must therefore say what it is. This
38
+ module cannot enforce that and does not claim to.
39
+
40
+ Consumed by ``scripts/skill_validator.py`` (per-skill score) and
41
+ ``scripts/marketplace_gen.py`` (``knowledge/skills-manifest.json``,
42
+ which carries the full origin/source/licence triple, not just origin).
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import re
48
+
49
+ import yaml
50
+ from pydantic import BaseModel, Field, ValidationError, model_validator
51
+
52
+ FIRST_PARTY = "arkaos"
53
+ METADATA_FIELDS = ("origin", "source", "license")
54
+
55
+ _FRONTMATTER = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL)
56
+ _ORIGIN_SLUG = re.compile(r"^[a-z][a-z0-9-]*$")
57
+ # https only: a licence trail that can be MITM'd is not a trail.
58
+ _URL = re.compile(r"^https://\S+$")
59
+ # Near-misses for `metadata` — a typo must not read as "no block".
60
+ _METADATA_TYPO = re.compile(r"^meta[-_ ]?datas?$", re.IGNORECASE)
61
+
62
+
63
+ class ProvenanceError(ValueError):
64
+ """The provenance block is present but cannot be trusted."""
65
+
66
+
67
+ class SkillProvenance(BaseModel):
68
+ """Where a skill came from, and under what licence."""
69
+
70
+ origin: str = Field(default=FIRST_PARTY)
71
+ source: str | None = None
72
+ license: str | None = None
73
+
74
+ @model_validator(mode="after")
75
+ def _derived_needs_a_trail(self) -> SkillProvenance:
76
+ if not _ORIGIN_SLUG.match(self.origin):
77
+ raise ValueError(
78
+ f"origin {self.origin!r} is not a lowercase slug "
79
+ f"(^[a-z][a-z0-9-]*$)"
80
+ )
81
+ if self.origin == FIRST_PARTY:
82
+ return self._first_party_carries_no_trail()
83
+ missing = [f for f in ("source", "license") if not getattr(self, f)]
84
+ if missing:
85
+ raise ValueError(
86
+ f"origin {self.origin!r} is third-party — "
87
+ f"{', '.join(missing)} required"
88
+ )
89
+ if not _URL.match(self.source or ""):
90
+ raise ValueError(f"source {self.source!r} is not an https URL")
91
+ return self
92
+
93
+ def _first_party_carries_no_trail(self) -> SkillProvenance:
94
+ """`arkaos` plus a source/licence is an unresolved contradiction.
95
+
96
+ Either the skill is ours and there is nothing to attribute, or
97
+ it is not and the origin is wrong. Silence would let a bad port
98
+ keep its upstream URL while claiming to be first-party.
99
+ """
100
+ declared = [f for f in ("source", "license") if getattr(self, f)]
101
+ if declared:
102
+ raise ValueError(
103
+ f"origin {FIRST_PARTY!r} is first-party but declares "
104
+ f"{', '.join(declared)} — set a third-party origin or "
105
+ f"drop the field"
106
+ )
107
+ return self
108
+
109
+ @property
110
+ def is_first_party(self) -> bool:
111
+ return self.origin == FIRST_PARTY
112
+
113
+
114
+ def _frontmatter(content: str) -> dict:
115
+ """The YAML frontmatter mapping. Raises when present but broken."""
116
+ match = _FRONTMATTER.match(content)
117
+ if not match:
118
+ return {}
119
+ try:
120
+ data = yaml.safe_load(match.group(1))
121
+ except yaml.YAMLError as exc:
122
+ detail = str(exc).replace("\n", " ")[:120]
123
+ raise ProvenanceError(
124
+ f"frontmatter YAML does not parse: {detail}"
125
+ ) from exc
126
+ if data is None:
127
+ return {}
128
+ if not isinstance(data, dict):
129
+ raise ProvenanceError("frontmatter is not a mapping")
130
+ return data
131
+
132
+
133
+ def _metadata_block(frontmatter: dict) -> dict | None:
134
+ """The `metadata` mapping, or None when genuinely absent."""
135
+ if "metadata" in frontmatter:
136
+ block = frontmatter["metadata"]
137
+ if not isinstance(block, dict):
138
+ raise ProvenanceError(
139
+ "metadata must be a mapping of origin/source/license"
140
+ )
141
+ return block
142
+ typos = [
143
+ key for key in frontmatter
144
+ if isinstance(key, str) and _METADATA_TYPO.match(key)
145
+ ]
146
+ if typos:
147
+ raise ProvenanceError(
148
+ f"key {typos[0]!r} is not read — did you mean 'metadata'?"
149
+ )
150
+ return None
151
+
152
+
153
+ def parse_provenance(content: str) -> SkillProvenance:
154
+ """Read a SKILL.md body. Absent metadata means first-party.
155
+
156
+ Raises ``ProvenanceError``/``ValidationError`` when the block is
157
+ present but incoherent — callers that must not raise use
158
+ ``provenance_issues`` instead.
159
+ """
160
+ block = _metadata_block(_frontmatter(content))
161
+ if block is None:
162
+ return SkillProvenance()
163
+ unknown = sorted(str(k) for k in block if k not in METADATA_FIELDS)
164
+ if unknown:
165
+ raise ProvenanceError(
166
+ f"unknown metadata keys: {', '.join(unknown)} "
167
+ f"(expected {', '.join(METADATA_FIELDS)})"
168
+ )
169
+ return SkillProvenance(**block)
170
+
171
+
172
+ def declares_provenance(content: str) -> bool:
173
+ """Did the author WRITE a block, or is this the default?
174
+
175
+ ``parse_provenance`` collapses "declared arkaos" and "said nothing"
176
+ into the same value — correct for reading, useless for the one
177
+ question the classification control has to answer.
178
+ """
179
+ try:
180
+ return _metadata_block(_frontmatter(content)) is not None
181
+ except ProvenanceError:
182
+ return True # broken but attempted; provenance_issues owns it
183
+
184
+
185
+ def provenance_issues(content: str) -> list[str]:
186
+ """Non-raising validation. Empty list means the skill is clean."""
187
+ try:
188
+ parse_provenance(content)
189
+ except ProvenanceError as exc:
190
+ return [str(exc)]
191
+ except ValidationError as exc:
192
+ return [
193
+ err["msg"].removeprefix("Value error, ") for err in exc.errors()
194
+ ]
195
+ return []
package/installer/cli.js CHANGED
@@ -68,6 +68,7 @@ Usage:
68
68
  npx arkaos models set <role> <provider>/<model> Re-route a role
69
69
  npx arkaos mcp start Start the arka-tools MCP server (stdio; --write enables writes)
70
70
  npx arkaos update --skills <curated|full> Choose the deployed skill set (default: curated on fresh installs)
71
+ npx arkaos shield Scan the harness config for vulnerabilities (--json)
71
72
  npx arkaos doctor Run health checks
72
73
  npx arkaos uninstall Remove ArkaOS
73
74
 
@@ -90,6 +91,8 @@ Examples:
90
91
  npx arkaos index Index knowledge base (Obsidian vault)
91
92
  npx arkaos search "query" Search indexed knowledge
92
93
  npx arkaos doctor Verify installation health
94
+ npx arkaos shield Scan the harness config (exit 2 = critical)
95
+ npx arkaos shield --json Machine-readable, for CI
93
96
  `);
94
97
  process.exit(0);
95
98
  }
@@ -208,6 +211,27 @@ async function main() {
208
211
  break;
209
212
  }
210
213
 
214
+ case "shield": {
215
+ // The harness config as attack surface. Exit code is the contract
216
+ // CI gates on (0 = A/B, 1 = C/D, 2 = F or any CRITICAL), so the
217
+ // child's code is propagated verbatim rather than collapsed to 1.
218
+ const { spawnSync } = await import("node:child_process");
219
+ const repoRootShield = join(__dirname, "..");
220
+ const pyShield = getArkaosPython();
221
+ if (!pyShield) { console.error("No Python found. Run: npx arkaos install"); process.exit(1); }
222
+ const shieldArgs = process.argv.slice(3);
223
+ const shieldRun = spawnSync(
224
+ pyShield,
225
+ ["-m", "core.governance.harness_scanner_cli", ...shieldArgs],
226
+ {
227
+ stdio: "inherit",
228
+ cwd: process.cwd(),
229
+ env: { ...process.env, ARKAOS_ROOT: repoRootShield, PYTHONPATH: repoRootShield },
230
+ }
231
+ );
232
+ process.exit(shieldRun.status === null ? 1 : shieldRun.status);
233
+ }
234
+
211
235
  case "index": {
212
236
  const { execFileSync } = await import("node:child_process");
213
237
  const repoRoot = join(__dirname, "..");
@@ -531,5 +531,51 @@ export async function doctor(options = {}) {
531
531
  }
532
532
 
533
533
  console.log(`\n Results: ${passed} passed, ${warned} warnings, ${failed} failures\n`);
534
+ await securityAdvisory();
534
535
  if (failed > 0) process.exit(1);
535
536
  }
537
+
538
+ // Doctor answers "is the install healthy?". It never answered "is the
539
+ // install SAFE?" — a config can be perfectly healthy and still hand a
540
+ // third party the right to run code on this machine. The scanner does
541
+ // that, and doctor is where the operator already looks.
542
+ //
543
+ // Advisory only: it prints the grade and never changes doctor's exit
544
+ // code. A health check that starts failing on a pre-existing security
545
+ // posture would be a breaking change to everyone's CI, and the way to
546
+ // get a security tool ignored is to make it block on day one.
547
+ async function securityAdvisory() {
548
+ const python = getArkaosPython();
549
+ const repoRoot = getRepoRoot();
550
+ if (!python || !repoRoot) return;
551
+ const { spawnSync } = await import("node:child_process");
552
+ const run = spawnSync(
553
+ python,
554
+ ["-m", "core.governance.harness_scanner_cli", "--json"],
555
+ {
556
+ encoding: "utf-8",
557
+ cwd: process.cwd(),
558
+ env: { ...process.env, ARKAOS_ROOT: repoRoot, PYTHONPATH: repoRoot },
559
+ }
560
+ );
561
+ // A crash must not read as "clean". If the scanner did not return a
562
+ // parseable report, say so — the same crash-is-not-clean rule the
563
+ // scanner enforces internally.
564
+ let report = null;
565
+ if (run.status !== null && run.stdout) {
566
+ try { report = JSON.parse(run.stdout); } catch { report = null; }
567
+ }
568
+ if (!report || !Array.isArray(report.findings)) {
569
+ console.log(" Security: scan did not complete — run `npx arkaos shield` directly\n");
570
+ return;
571
+ }
572
+
573
+ const cross = process.stdout.isTTY ? "\x1b[31m✗\x1b[0m" : "x";
574
+ const n = report.findings.length;
575
+ const plural = n === 1 ? "finding" : "findings";
576
+ console.log(` Security: grade ${report.grade} (${report.score}/100), ${n} ${plural}`);
577
+ for (const finding of report.findings.filter((f) => f.severity === "critical").slice(0, 3)) {
578
+ console.log(` ${cross} ${finding.rule} — ${finding.where}`);
579
+ }
580
+ console.log(n > 0 ? " Run: npx arkaos shield\n" : "");
581
+ }