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.
- package/VERSION +1 -1
- package/config/skills-provenance.yaml +338 -0
- package/core/governance/harness_scanner.py +776 -0
- package/core/governance/harness_scanner_cli.py +131 -0
- package/core/skills/__init__.py +21 -0
- package/core/skills/provenance.py +195 -0
- package/installer/cli.js +24 -0
- package/installer/doctor.js +46 -0
- package/knowledge/skills-manifest.json +945 -237
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/marketplace_gen.py +57 -1
- package/scripts/skill_validator.py +133 -62
|
@@ -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, "..");
|
package/installer/doctor.js
CHANGED
|
@@ -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
|
+
}
|