codex-plugin-scanner 1.0.0__py3-none-any.whl

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,14 @@
1
+ """Codex Plugin Scanner - security and best-practices scanner for Codex CLI plugins."""
2
+
3
+ from .models import GRADE_LABELS, CategoryResult, CheckResult, ScanResult, get_grade
4
+ from .scanner import scan_plugin
5
+
6
+ __version__ = "1.0.0"
7
+ __all__ = [
8
+ "GRADE_LABELS",
9
+ "CategoryResult",
10
+ "CheckResult",
11
+ "ScanResult",
12
+ "get_grade",
13
+ "scan_plugin",
14
+ ]
File without changes
@@ -0,0 +1,171 @@
1
+ """Best practice checks (25 points)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ..models import CheckResult
8
+ from .manifest import load_manifest
9
+
10
+ ENV_FILES = {".env", ".env.local", ".env.production"}
11
+
12
+
13
+ def check_readme(plugin_dir: Path) -> CheckResult:
14
+ exists = (plugin_dir / "README.md").exists()
15
+ return CheckResult(
16
+ name="README.md found",
17
+ passed=exists,
18
+ points=5 if exists else 0,
19
+ max_points=5,
20
+ message="README.md found" if exists else "README.md not found",
21
+ )
22
+
23
+
24
+ def check_skills_directory(plugin_dir: Path) -> CheckResult:
25
+ manifest = load_manifest(plugin_dir)
26
+ if manifest is None:
27
+ return CheckResult(
28
+ name="Skills directory exists if declared",
29
+ passed=True,
30
+ points=5,
31
+ max_points=5,
32
+ message="Cannot parse manifest, skipping check",
33
+ )
34
+ skills = manifest.get("skills")
35
+ if not skills:
36
+ return CheckResult(
37
+ name="Skills directory exists if declared",
38
+ passed=True,
39
+ points=5,
40
+ max_points=5,
41
+ message="No skills field declared, check not applicable",
42
+ )
43
+ skills_path = plugin_dir / skills
44
+ if skills_path.is_dir():
45
+ return CheckResult(
46
+ name="Skills directory exists if declared",
47
+ passed=True,
48
+ points=5,
49
+ max_points=5,
50
+ message=f'Skills directory "{skills}" exists',
51
+ )
52
+ return CheckResult(
53
+ name="Skills directory exists if declared",
54
+ passed=False,
55
+ points=0,
56
+ max_points=5,
57
+ message=f'Skills directory "{skills}" declared but not found',
58
+ )
59
+
60
+
61
+ def check_skill_frontmatter(plugin_dir: Path) -> CheckResult:
62
+ manifest = load_manifest(plugin_dir)
63
+ if manifest is None:
64
+ return CheckResult(
65
+ name="SKILL.md frontmatter",
66
+ passed=True,
67
+ points=5,
68
+ max_points=5,
69
+ message="Cannot parse manifest, skipping check",
70
+ )
71
+ skills = manifest.get("skills")
72
+ if not skills:
73
+ return CheckResult(
74
+ name="SKILL.md frontmatter",
75
+ passed=True,
76
+ points=5,
77
+ max_points=5,
78
+ message="No skills field declared, check not applicable",
79
+ )
80
+ skills_path = plugin_dir / skills
81
+ if not skills_path.is_dir():
82
+ return CheckResult(
83
+ name="SKILL.md frontmatter",
84
+ passed=True,
85
+ points=5,
86
+ max_points=5,
87
+ message="Skills directory not found, skipping check",
88
+ )
89
+
90
+ skill_files = list(skills_path.glob("*/SKILL.md"))
91
+ if not skill_files:
92
+ return CheckResult(
93
+ name="SKILL.md frontmatter",
94
+ passed=True,
95
+ points=5,
96
+ max_points=5,
97
+ message="No SKILL.md files found, nothing to check",
98
+ )
99
+
100
+ issues: list[str] = []
101
+ for sf in skill_files:
102
+ try:
103
+ content = sf.read_text(encoding="utf-8")
104
+ except OSError: # pragma: no cover
105
+ continue
106
+ if "---" not in content:
107
+ issues.append(str(sf.relative_to(plugin_dir)))
108
+ continue
109
+ parts = content.split("---", 2)
110
+ if len(parts) < 3:
111
+ issues.append(str(sf.relative_to(plugin_dir)))
112
+ continue
113
+ fm = parts[1]
114
+ if "name:" not in fm or "description:" not in fm:
115
+ issues.append(str(sf.relative_to(plugin_dir)))
116
+
117
+ if not issues:
118
+ return CheckResult(
119
+ name="SKILL.md frontmatter",
120
+ passed=True,
121
+ points=5,
122
+ max_points=5,
123
+ message="All SKILL.md files have valid frontmatter",
124
+ )
125
+ return CheckResult(
126
+ name="SKILL.md frontmatter",
127
+ passed=False,
128
+ points=0,
129
+ max_points=5,
130
+ message=f"SKILL.md missing valid frontmatter in: {', '.join(issues)}",
131
+ )
132
+
133
+
134
+ def check_no_env_files(plugin_dir: Path) -> CheckResult:
135
+ found = [f for f in ENV_FILES if (plugin_dir / f).exists()]
136
+ if not found:
137
+ return CheckResult(
138
+ name="No .env files committed",
139
+ passed=True,
140
+ points=5,
141
+ max_points=5,
142
+ message="No .env files found",
143
+ )
144
+ return CheckResult(
145
+ name="No .env files committed",
146
+ passed=False,
147
+ points=0,
148
+ max_points=5,
149
+ message=f".env files found: {', '.join(found)}",
150
+ )
151
+
152
+
153
+ def check_codexignore(plugin_dir: Path) -> CheckResult:
154
+ exists = (plugin_dir / ".codexignore").exists()
155
+ return CheckResult(
156
+ name=".codexignore found",
157
+ passed=exists,
158
+ points=5 if exists else 0,
159
+ max_points=5,
160
+ message=".codexignore found" if exists else ".codexignore not found",
161
+ )
162
+
163
+
164
+ def run_best_practice_checks(plugin_dir: Path) -> tuple[CheckResult, ...]:
165
+ return (
166
+ check_readme(plugin_dir),
167
+ check_skills_directory(plugin_dir),
168
+ check_skill_frontmatter(plugin_dir),
169
+ check_no_env_files(plugin_dir),
170
+ check_codexignore(plugin_dir),
171
+ )
@@ -0,0 +1,91 @@
1
+ """Code quality checks (10 points)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from ..models import CheckResult
9
+
10
+ CODE_EXTS = {".py", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"}
11
+ EXCLUDED_DIRS = {"node_modules", ".git", "dist", ".next", "coverage", "__pycache__", ".venv", "venv"}
12
+
13
+ EVAL_RE = re.compile(r"\beval\s*\(")
14
+ FUNCTION_RE = re.compile(r"new\s+Function\s*\(")
15
+ SHELL_INJECT_RE = re.compile(
16
+ r"`[^`]*\$\{[^}]+\}[^`]*`"
17
+ r"[\s\S]{0,30}"
18
+ r"\b(exec|spawn|execSync|spawnSync|os\.system|subprocess)\b"
19
+ )
20
+
21
+
22
+ def _find_code_files(plugin_dir: Path) -> list[Path]:
23
+ files = []
24
+ for p in plugin_dir.rglob("*"):
25
+ if not p.is_file() or p.suffix not in CODE_EXTS:
26
+ continue
27
+ if any(part in EXCLUDED_DIRS for part in p.parts):
28
+ continue
29
+ files.append(p)
30
+ return files
31
+
32
+
33
+ def check_no_eval(plugin_dir: Path) -> CheckResult:
34
+ findings: list[str] = []
35
+ for fpath in _find_code_files(plugin_dir):
36
+ try:
37
+ content = fpath.read_text(encoding="utf-8", errors="ignore")
38
+ except OSError:
39
+ continue
40
+ if EVAL_RE.search(content):
41
+ findings.append(f"{fpath.relative_to(plugin_dir)}: eval()")
42
+ if FUNCTION_RE.search(content):
43
+ findings.append(f"{fpath.relative_to(plugin_dir)}: new Function()")
44
+ if not findings:
45
+ return CheckResult(
46
+ name="No eval or Function constructor",
47
+ passed=True,
48
+ points=5,
49
+ max_points=5,
50
+ message="No eval() or new Function() usage detected",
51
+ )
52
+ return CheckResult(
53
+ name="No eval or Function constructor",
54
+ passed=False,
55
+ points=0,
56
+ max_points=5,
57
+ message=f"Found: {', '.join(findings[:3])}",
58
+ )
59
+
60
+
61
+ def check_no_shell_injection(plugin_dir: Path) -> CheckResult:
62
+ findings: list[str] = []
63
+ for fpath in _find_code_files(plugin_dir):
64
+ try:
65
+ content = fpath.read_text(encoding="utf-8", errors="ignore")
66
+ except OSError:
67
+ continue
68
+ if SHELL_INJECT_RE.search(content):
69
+ findings.append(str(fpath.relative_to(plugin_dir)))
70
+ if not findings:
71
+ return CheckResult(
72
+ name="No shell injection patterns",
73
+ passed=True,
74
+ points=5,
75
+ max_points=5,
76
+ message="No shell injection patterns detected",
77
+ )
78
+ return CheckResult(
79
+ name="No shell injection patterns",
80
+ passed=False,
81
+ points=0,
82
+ max_points=5,
83
+ message=f"Shell injection patterns in: {', '.join(findings)}",
84
+ )
85
+
86
+
87
+ def run_code_quality_checks(plugin_dir: Path) -> tuple[CheckResult, ...]:
88
+ return (
89
+ check_no_eval(plugin_dir),
90
+ check_no_shell_injection(plugin_dir),
91
+ )
@@ -0,0 +1,121 @@
1
+ """Manifest validation checks (25 points)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+
9
+ from ..models import CheckResult
10
+
11
+ SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+")
12
+ KEBAB_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
13
+
14
+
15
+ def load_manifest(plugin_dir: Path) -> dict | None:
16
+ p = plugin_dir / ".codex-plugin" / "plugin.json"
17
+ if not p.exists():
18
+ return None
19
+ try:
20
+ return json.loads(p.read_text(encoding="utf-8"))
21
+ except (json.JSONDecodeError, OSError):
22
+ return None
23
+
24
+
25
+ def check_plugin_json_exists(plugin_dir: Path) -> CheckResult:
26
+ p = plugin_dir / ".codex-plugin" / "plugin.json"
27
+ exists = p.exists()
28
+ return CheckResult(
29
+ name="plugin.json exists",
30
+ passed=exists,
31
+ points=5 if exists else 0,
32
+ max_points=5,
33
+ message="plugin.json found" if exists else "plugin.json not found at .codex-plugin/plugin.json",
34
+ )
35
+
36
+
37
+ def check_valid_json(plugin_dir: Path) -> CheckResult:
38
+ p = plugin_dir / ".codex-plugin" / "plugin.json"
39
+ try:
40
+ content = p.read_text(encoding="utf-8")
41
+ json.loads(content)
42
+ return CheckResult(name="Valid JSON", passed=True, points=5, max_points=5, message="plugin.json is valid JSON")
43
+ except Exception:
44
+ return CheckResult(
45
+ name="Valid JSON", passed=False, points=0, max_points=5, message="plugin.json is not valid JSON"
46
+ )
47
+
48
+
49
+ def check_required_fields(plugin_dir: Path) -> CheckResult:
50
+ manifest = load_manifest(plugin_dir)
51
+ if manifest is None:
52
+ return CheckResult(
53
+ name="Required fields present", passed=False, points=0, max_points=8, message="Cannot parse plugin.json"
54
+ )
55
+ required = ["name", "version", "description"]
56
+ missing = [f for f in required if not manifest.get(f) or not isinstance(manifest.get(f), str)]
57
+ if not missing:
58
+ return CheckResult(
59
+ name="Required fields present",
60
+ passed=True,
61
+ points=8,
62
+ max_points=8,
63
+ message="All required fields (name, version, description) present",
64
+ )
65
+ return CheckResult(
66
+ name="Required fields present",
67
+ passed=False,
68
+ points=0,
69
+ max_points=8,
70
+ message=f"Missing required fields: {', '.join(missing)}",
71
+ )
72
+
73
+
74
+ def check_semver(plugin_dir: Path) -> CheckResult:
75
+ manifest = load_manifest(plugin_dir)
76
+ if manifest is None:
77
+ return CheckResult(
78
+ name="Version follows semver", passed=False, points=0, max_points=4, message="Cannot parse plugin.json"
79
+ )
80
+ version = manifest.get("version", "")
81
+ if version and SEMVER_RE.match(str(version)):
82
+ return CheckResult(
83
+ name="Version follows semver",
84
+ passed=True,
85
+ points=4,
86
+ max_points=4,
87
+ message=f'Version "{version}" follows semver',
88
+ )
89
+ return CheckResult(
90
+ name="Version follows semver",
91
+ passed=False,
92
+ points=0,
93
+ max_points=4,
94
+ message=f'Version "{version}" does not follow semver (expected X.Y.Z)',
95
+ )
96
+
97
+
98
+ def check_kebab_case(plugin_dir: Path) -> CheckResult:
99
+ manifest = load_manifest(plugin_dir)
100
+ if manifest is None:
101
+ return CheckResult(
102
+ name="Name is kebab-case", passed=False, points=0, max_points=3, message="Cannot parse plugin.json"
103
+ )
104
+ name = manifest.get("name", "")
105
+ if name and KEBAB_RE.match(str(name)):
106
+ return CheckResult(
107
+ name="Name is kebab-case", passed=True, points=3, max_points=3, message=f'Name "{name}" is kebab-case'
108
+ )
109
+ return CheckResult(
110
+ name="Name is kebab-case", passed=False, points=0, max_points=3, message=f'Name "{name}" should be kebab-case'
111
+ )
112
+
113
+
114
+ def run_manifest_checks(plugin_dir: Path) -> tuple[CheckResult, ...]:
115
+ return (
116
+ check_plugin_json_exists(plugin_dir),
117
+ check_valid_json(plugin_dir),
118
+ check_required_fields(plugin_dir),
119
+ check_semver(plugin_dir),
120
+ check_kebab_case(plugin_dir),
121
+ )
@@ -0,0 +1,130 @@
1
+ """Marketplace validation checks (10 points)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from ..models import CheckResult
9
+
10
+
11
+ def check_marketplace_json(plugin_dir: Path) -> CheckResult:
12
+ mp = plugin_dir / "marketplace.json"
13
+ if not mp.exists():
14
+ return CheckResult(
15
+ name="marketplace.json valid",
16
+ passed=True,
17
+ points=5,
18
+ max_points=5,
19
+ message="No marketplace.json found, check not applicable",
20
+ )
21
+ try:
22
+ data = json.loads(mp.read_text(encoding="utf-8"))
23
+ except json.JSONDecodeError:
24
+ return CheckResult(
25
+ name="marketplace.json valid",
26
+ passed=False,
27
+ points=0,
28
+ max_points=5,
29
+ message="marketplace.json is not valid JSON",
30
+ )
31
+
32
+ if not data.get("name") or not isinstance(data.get("name"), str):
33
+ return CheckResult(
34
+ name="marketplace.json valid",
35
+ passed=False,
36
+ points=0,
37
+ max_points=5,
38
+ message='marketplace.json missing "name" field',
39
+ )
40
+ if not isinstance(data.get("plugins"), list):
41
+ return CheckResult(
42
+ name="marketplace.json valid",
43
+ passed=False,
44
+ points=0,
45
+ max_points=5,
46
+ message='marketplace.json missing "plugins" array',
47
+ )
48
+ for i, plugin in enumerate(data["plugins"]):
49
+ if not plugin.get("source") or not isinstance(plugin.get("source"), str):
50
+ return CheckResult(
51
+ name="marketplace.json valid",
52
+ passed=False,
53
+ points=0,
54
+ max_points=5,
55
+ message=f'marketplace.json plugin[{i}] missing "source" field',
56
+ )
57
+ if not plugin.get("policy") or not isinstance(plugin.get("policy"), dict):
58
+ return CheckResult(
59
+ name="marketplace.json valid",
60
+ passed=False,
61
+ points=0,
62
+ max_points=5,
63
+ message=f'marketplace.json plugin[{i}] missing "policy" field',
64
+ )
65
+ return CheckResult(
66
+ name="marketplace.json valid", passed=True, points=5, max_points=5, message="marketplace.json is valid"
67
+ )
68
+
69
+
70
+ def check_policy_fields(plugin_dir: Path) -> CheckResult:
71
+ mp = plugin_dir / "marketplace.json"
72
+ if not mp.exists():
73
+ return CheckResult(
74
+ name="Policy fields present",
75
+ passed=True,
76
+ points=5,
77
+ max_points=5,
78
+ message="No marketplace.json found, check not applicable",
79
+ )
80
+ try:
81
+ data = json.loads(mp.read_text(encoding="utf-8"))
82
+ except json.JSONDecodeError:
83
+ return CheckResult(
84
+ name="Policy fields present",
85
+ passed=True,
86
+ points=5,
87
+ max_points=5,
88
+ message="Cannot parse marketplace.json, skipping check",
89
+ )
90
+
91
+ plugins = data.get("plugins", [])
92
+ if not plugins:
93
+ return CheckResult(
94
+ name="Policy fields present",
95
+ passed=True,
96
+ points=5,
97
+ max_points=5,
98
+ message="No plugins in marketplace.json, nothing to check",
99
+ )
100
+
101
+ issues: list[str] = []
102
+ for i, plugin in enumerate(plugins):
103
+ policy = plugin.get("policy") or {}
104
+ if not policy.get("installation"):
105
+ issues.append(f"plugin[{i}]: missing policy.installation")
106
+ if not policy.get("authentication"):
107
+ issues.append(f"plugin[{i}]: missing policy.authentication")
108
+
109
+ if not issues:
110
+ return CheckResult(
111
+ name="Policy fields present",
112
+ passed=True,
113
+ points=5,
114
+ max_points=5,
115
+ message="All plugins have required policy fields",
116
+ )
117
+ return CheckResult(
118
+ name="Policy fields present",
119
+ passed=False,
120
+ points=0,
121
+ max_points=5,
122
+ message=f"Policy issues: {', '.join(issues[:3])}",
123
+ )
124
+
125
+
126
+ def run_marketplace_checks(plugin_dir: Path) -> tuple[CheckResult, ...]:
127
+ return (
128
+ check_marketplace_json(plugin_dir),
129
+ check_policy_fields(plugin_dir),
130
+ )
@@ -0,0 +1,185 @@
1
+ """Security checks (30 points)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from ..models import CheckResult
9
+
10
+ # Patterns for hardcoded secrets
11
+ SECRET_PATTERNS: list[re.Pattern[str]] = [
12
+ re.compile(r"AKIA[0-9A-Z]{16}"), # AWS access key
13
+ re.compile(r"aws_secret_access_key\s*[=:]\s*[\"']?[A-Za-z0-9/+=]{40}", re.I),
14
+ re.compile(r"-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"),
15
+ re.compile(r"password\s*[=:]\s*[\"'][^\s\"']{8,}", re.I),
16
+ re.compile(r"secret\s*[=:]\s*[\"'][^\s\"']{8,}", re.I),
17
+ re.compile(r"token\s*[=:]\s*[\"'][^\s\"']{8,}", re.I),
18
+ re.compile(r"api_?key\s*[=:]\s*[\"'][^\s\"']{8,}", re.I),
19
+ re.compile(r"API_KEY\s*[=:]\s*[\"'][^\s\"']{8,}"),
20
+ re.compile(r"PRIVATE_KEY\s*[=:]\s*[\"'][^\s\"']{8,}"),
21
+ re.compile(r"ghp_[A-Za-z0-9]{36}"), # GitHub PAT
22
+ re.compile(r"gho_[A-Za-z0-9]{36}"), # GitHub OAuth
23
+ re.compile(r"ghu_[A-Za-z0-9]{36}"), # GitHub user token
24
+ re.compile(r"ghs_[A-Za-z0-9]{36}"), # GitHub app token
25
+ re.compile(r"glpat-[A-Za-z0-9\-]{20}"), # GitLab PAT
26
+ re.compile(r"xox[bpas]-[A-Za-z0-9\-]{10,}"), # Slack tokens
27
+ re.compile(r"sk-[A-Za-z0-9]{48}"), # OpenAI key
28
+ ]
29
+
30
+ EXCLUDED_DIRS = {"node_modules", ".git", "dist", ".next", "coverage", ".turbo", "__pycache__", ".venv", "venv"}
31
+
32
+ BINARY_EXTS = {
33
+ ".png",
34
+ ".jpg",
35
+ ".jpeg",
36
+ ".gif",
37
+ ".ico",
38
+ ".svg",
39
+ ".webp",
40
+ ".woff",
41
+ ".woff2",
42
+ ".ttf",
43
+ ".eot",
44
+ ".otf",
45
+ ".zip",
46
+ ".tar",
47
+ ".gz",
48
+ ".7z",
49
+ ".rar",
50
+ ".lock",
51
+ ".wasm",
52
+ ".pyc",
53
+ ".so",
54
+ ".dylib",
55
+ }
56
+
57
+ DANGEROUS_MCP_PATTERNS: list[re.Pattern[str]] = [
58
+ re.compile(r"rm\s+-rf"),
59
+ re.compile(r"\bsudo\b"),
60
+ re.compile(r"curl\b.*\|\s*(ba)?sh"),
61
+ re.compile(r"wget\b.*\|\s*(ba)?sh"),
62
+ re.compile(r"bash\s+-c"),
63
+ re.compile(r"\beval\b"),
64
+ re.compile(r"\bexec\b"),
65
+ re.compile(r"powershell\s+-c", re.I),
66
+ re.compile(r"cmd\s*/c", re.I),
67
+ ]
68
+
69
+
70
+ def _scan_all_files(plugin_dir: Path) -> list[Path]:
71
+ """Recursively find all files, skipping excluded dirs."""
72
+ files = []
73
+ for p in plugin_dir.rglob("*"):
74
+ if not p.is_file():
75
+ continue
76
+ if any(part in EXCLUDED_DIRS for part in p.parts):
77
+ continue
78
+ if p.suffix.lower() in BINARY_EXTS:
79
+ continue
80
+ files.append(p)
81
+ return files
82
+
83
+
84
+ def check_security_md(plugin_dir: Path) -> CheckResult:
85
+ exists = (plugin_dir / "SECURITY.md").exists()
86
+ return CheckResult(
87
+ name="SECURITY.md found",
88
+ passed=exists,
89
+ points=5 if exists else 0,
90
+ max_points=5,
91
+ message="SECURITY.md found" if exists else "SECURITY.md not found",
92
+ )
93
+
94
+
95
+ def check_license(plugin_dir: Path) -> CheckResult:
96
+ lp = plugin_dir / "LICENSE"
97
+ if not lp.exists():
98
+ return CheckResult(name="LICENSE found", passed=False, points=0, max_points=5, message="LICENSE file not found")
99
+ try:
100
+ content = lp.read_text(encoding="utf-8", errors="ignore")
101
+ if "Apache" in content and ("2.0" in content or "www.apache.org" in content):
102
+ return CheckResult(
103
+ name="LICENSE found", passed=True, points=5, max_points=5, message="LICENSE found (Apache-2.0)"
104
+ )
105
+ if "MIT" in content and "Permission is hereby granted" in content:
106
+ return CheckResult(name="LICENSE found", passed=True, points=5, max_points=5, message="LICENSE found (MIT)")
107
+ return CheckResult(
108
+ name="LICENSE found", passed=True, points=5, max_points=5, message="LICENSE found (not Apache-2.0 or MIT)"
109
+ )
110
+ except OSError:
111
+ return CheckResult(
112
+ name="LICENSE found", passed=False, points=0, max_points=5, message="LICENSE exists but could not be read"
113
+ )
114
+
115
+
116
+ def check_no_hardcoded_secrets(plugin_dir: Path) -> CheckResult:
117
+ findings: list[str] = []
118
+ for fpath in _scan_all_files(plugin_dir):
119
+ try:
120
+ content = fpath.read_text(encoding="utf-8", errors="ignore")
121
+ except OSError:
122
+ continue
123
+ for pattern in SECRET_PATTERNS:
124
+ if pattern.search(content):
125
+ findings.append(str(fpath.relative_to(plugin_dir)))
126
+ break
127
+ if not findings:
128
+ return CheckResult(
129
+ name="No hardcoded secrets", passed=True, points=10, max_points=10, message="No hardcoded secrets detected"
130
+ )
131
+ shown = findings[:5]
132
+ suffix = f" and {len(findings) - 5} more" if len(findings) > 5 else ""
133
+ return CheckResult(
134
+ name="No hardcoded secrets",
135
+ passed=False,
136
+ points=0,
137
+ max_points=10,
138
+ message=f"Hardcoded secrets found in: {', '.join(shown)}{suffix}",
139
+ )
140
+
141
+
142
+ def check_no_dangerous_mcp(plugin_dir: Path) -> CheckResult:
143
+ mcp_path = plugin_dir / ".mcp.json"
144
+ if not mcp_path.exists():
145
+ return CheckResult(
146
+ name="No dangerous MCP commands",
147
+ passed=True,
148
+ points=10,
149
+ max_points=10,
150
+ message="No .mcp.json found, skipping check",
151
+ )
152
+ try:
153
+ content = mcp_path.read_text(encoding="utf-8")
154
+ except OSError:
155
+ return CheckResult(
156
+ name="No dangerous MCP commands", passed=True, points=10, max_points=10, message="Could not read .mcp.json"
157
+ )
158
+ found: list[str] = []
159
+ for pattern in DANGEROUS_MCP_PATTERNS:
160
+ if pattern.search(content):
161
+ found.append(pattern.pattern)
162
+ if not found:
163
+ return CheckResult(
164
+ name="No dangerous MCP commands",
165
+ passed=True,
166
+ points=10,
167
+ max_points=10,
168
+ message="No dangerous commands found in .mcp.json",
169
+ )
170
+ return CheckResult(
171
+ name="No dangerous MCP commands",
172
+ passed=False,
173
+ points=0,
174
+ max_points=10,
175
+ message=f"Dangerous patterns in .mcp.json: {', '.join(found)}",
176
+ )
177
+
178
+
179
+ def run_security_checks(plugin_dir: Path) -> tuple[CheckResult, ...]:
180
+ return (
181
+ check_security_md(plugin_dir),
182
+ check_license(plugin_dir),
183
+ check_no_hardcoded_secrets(plugin_dir),
184
+ check_no_dangerous_mcp(plugin_dir),
185
+ )
@@ -0,0 +1,154 @@
1
+ """Codex Plugin Scanner - CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from .models import GRADE_LABELS
11
+ from .scanner import scan_plugin
12
+
13
+ __version__ = "1.0.0"
14
+
15
+
16
+ def _build_plain_text(result) -> str:
17
+ """Build plain text output from a scan result."""
18
+ lines = [f"🔗 Codex Plugin Scanner v{__version__}", f"Scanning: {result.plugin_dir}", ""]
19
+ for category in result.categories:
20
+ cat_score = sum(c.points for c in category.checks)
21
+ cat_max = sum(c.max_points for c in category.checks)
22
+ lines.append(f"── {category.name} ({cat_score}/{cat_max}) ──")
23
+ for check in category.checks:
24
+ icon = "✅" if check.passed else "⚠️"
25
+ pts = f"+{check.points}" if check.passed else "+0"
26
+ lines.append(f" {icon} {check.name:<42} {pts}")
27
+ lines.append("")
28
+ separator = "━" * 37
29
+ label = GRADE_LABELS.get(result.grade, "Unknown")
30
+ lines += [separator, f"Final Score: {result.score}/100 ({result.grade} - {label})", separator]
31
+ return "\n".join(lines)
32
+
33
+
34
+ def _build_rich_text(result) -> str:
35
+ """Build rich markup text from a scan result."""
36
+ lines = [f"[bold cyan]🔗 Codex Plugin Scanner v{__version__}[/bold cyan]"]
37
+ lines.append(f"Scanning: {result.plugin_dir}")
38
+ lines.append("")
39
+ for category in result.categories:
40
+ cat_score = sum(c.points for c in category.checks)
41
+ cat_max = sum(c.max_points for c in category.checks)
42
+ lines.append(f"[bold yellow]── {category.name} ({cat_score}/{cat_max}) ──[/bold yellow]")
43
+ for check in category.checks:
44
+ icon = "✅" if check.passed else "⚠️"
45
+ style = "[green]" if check.passed else "[red]"
46
+ pts = f"[green]+{check.points}[/green]" if check.passed else "[red]+0[/red]"
47
+ lines.append(f" {icon} {style}{check.name:<42}[/]{pts}")
48
+ lines.append("")
49
+ separator = "━" * 37
50
+ grade = result.grade
51
+ gc = {"A": "bold green", "B": "green", "C": "yellow", "D": "red", "F": "bold red"}.get(grade, "red")
52
+ label = GRADE_LABELS.get(grade, "Unknown")
53
+ lines += [
54
+ f"[bold]{separator}[/bold]",
55
+ f"Final Score: [bold]{result.score}[/bold]/100 ([{gc}]{grade} - {label}[/{gc}])",
56
+ f"[bold]{separator}[/bold]",
57
+ ]
58
+ return "\n".join(lines)
59
+
60
+
61
+ def format_text(result) -> str:
62
+ """Format scan result as terminal output. Returns plain text string."""
63
+ plain = _build_plain_text(result)
64
+
65
+ try:
66
+ from rich.console import Console
67
+
68
+ console = Console()
69
+ console.print(_build_rich_text(result))
70
+ except ImportError:
71
+ print(plain)
72
+
73
+ return plain
74
+
75
+
76
+ def format_json(result) -> str:
77
+ """Format scan result as JSON."""
78
+ data = {
79
+ "score": result.score,
80
+ "grade": result.grade,
81
+ "categories": [
82
+ {
83
+ "name": cat.name,
84
+ "score": sum(c.points for c in cat.checks),
85
+ "max": sum(c.max_points for c in cat.checks),
86
+ "checks": [
87
+ {
88
+ "name": c.name,
89
+ "passed": c.passed,
90
+ "points": c.points,
91
+ "maxPoints": c.max_points,
92
+ "message": c.message,
93
+ }
94
+ for c in cat.checks
95
+ ],
96
+ }
97
+ for cat in result.categories
98
+ ],
99
+ "timestamp": result.timestamp,
100
+ "pluginDir": result.plugin_dir,
101
+ }
102
+ return json.dumps(data, indent=2)
103
+
104
+
105
+ def main(argv: list[str] | None = None) -> int:
106
+ """Run the CLI. Returns exit code."""
107
+ parser = argparse.ArgumentParser(
108
+ prog="codex-plugin-scanner",
109
+ description="Scan a Codex plugin directory for best practices and security",
110
+ )
111
+ parser.add_argument("plugin_dir", help="Path to the plugin directory to scan")
112
+ parser.add_argument("--json", action="store_true", help="Output results as JSON")
113
+ parser.add_argument("--output", "-o", help="Write JSON report to file")
114
+ parser.add_argument(
115
+ "--min-score",
116
+ type=int,
117
+ default=0,
118
+ help="Exit with code 1 if score is below this threshold (default: 0)",
119
+ )
120
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
121
+ args = parser.parse_args(argv)
122
+
123
+ resolved = Path(args.plugin_dir).resolve()
124
+ if not resolved.is_dir():
125
+ print(f'Error: "{resolved}" is not a directory.', file=sys.stderr)
126
+ return 1
127
+
128
+ result = scan_plugin(args.plugin_dir)
129
+
130
+ if args.json or args.output:
131
+ output = format_json(result)
132
+ if args.output:
133
+ out_path = Path(args.output)
134
+ out_path.write_text(output, encoding="utf-8")
135
+ print(f"Report written to {out_path}")
136
+ else:
137
+ print(output)
138
+ else:
139
+ text = format_text(result)
140
+ if text:
141
+ print(text)
142
+
143
+ if result.score < args.min_score:
144
+ print(
145
+ f"Score {result.score} is below minimum threshold {args.min_score}",
146
+ file=sys.stderr,
147
+ )
148
+ return 1
149
+
150
+ return 0
151
+
152
+
153
+ if __name__ == "__main__":
154
+ raise SystemExit(main()) # pragma: no cover
@@ -0,0 +1,57 @@
1
+ """Codex Plugin Scanner - types and data classes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class CheckResult:
10
+ """Result of an individual check."""
11
+
12
+ name: str
13
+ passed: bool
14
+ points: int
15
+ max_points: int
16
+ message: str
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class CategoryResult:
21
+ """A category containing multiple checks."""
22
+
23
+ name: str
24
+ checks: tuple[CheckResult, ...]
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class ScanResult:
29
+ """Full result of scanning a plugin directory."""
30
+
31
+ score: int
32
+ grade: str
33
+ categories: tuple[CategoryResult, ...]
34
+ timestamp: str
35
+ plugin_dir: str
36
+
37
+
38
+ def get_grade(score: int) -> str:
39
+ """Convert a numeric score to a letter grade."""
40
+ if score >= 90:
41
+ return "A"
42
+ if score >= 80:
43
+ return "B"
44
+ if score >= 70:
45
+ return "C"
46
+ if score >= 60:
47
+ return "D"
48
+ return "F"
49
+
50
+
51
+ GRADE_LABELS: dict[str, str] = {
52
+ "A": "Excellent",
53
+ "B": "Good",
54
+ "C": "Acceptable",
55
+ "D": "Needs Improvement",
56
+ "F": "Failing",
57
+ }
@@ -0,0 +1,44 @@
1
+ """Codex Plugin Scanner - core scanning engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ from .checks.best_practices import run_best_practice_checks
9
+ from .checks.code_quality import run_code_quality_checks
10
+ from .checks.manifest import run_manifest_checks
11
+ from .checks.marketplace import run_marketplace_checks
12
+ from .checks.security import run_security_checks
13
+ from .models import CategoryResult, ScanResult, get_grade
14
+
15
+
16
+ def scan_plugin(plugin_dir: str | Path) -> ScanResult:
17
+ """Scan a Codex plugin directory and return a scored result.
18
+
19
+ Args:
20
+ plugin_dir: Path to the plugin directory to scan.
21
+
22
+ Returns:
23
+ ScanResult with score 0-100, grade A-F, and per-category breakdowns.
24
+ """
25
+ resolved = Path(plugin_dir).resolve()
26
+
27
+ categories: list[CategoryResult] = [
28
+ CategoryResult(name="Manifest Validation", checks=run_manifest_checks(resolved)),
29
+ CategoryResult(name="Security", checks=run_security_checks(resolved)),
30
+ CategoryResult(name="Best Practices", checks=run_best_practice_checks(resolved)),
31
+ CategoryResult(name="Marketplace", checks=run_marketplace_checks(resolved)),
32
+ CategoryResult(name="Code Quality", checks=run_code_quality_checks(resolved)),
33
+ ]
34
+
35
+ score = sum(c.points for cat in categories for c in cat.checks)
36
+ grade = get_grade(score)
37
+
38
+ return ScanResult(
39
+ score=score,
40
+ grade=grade,
41
+ categories=tuple(categories),
42
+ timestamp=datetime.now(timezone.utc).isoformat(),
43
+ plugin_dir=str(resolved),
44
+ )
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.4
2
+ Name: codex-plugin-scanner
3
+ Version: 1.0.0
4
+ Summary: Security and best-practices scanner for Codex CLI plugins. Scores plugins 0-100.
5
+ Project-URL: Homepage, https://github.com/hashgraph-online/codex-plugin-scanner
6
+ Project-URL: Repository, https://github.com/hashgraph-online/codex-plugin-scanner
7
+ Project-URL: Issues, https://github.com/hashgraph-online/codex-plugin-scanner/issues
8
+ Author-email: Hashgraph Online <dev@hol.org>
9
+ License-Expression: Apache-2.0
10
+ License-File: LICENSE
11
+ Keywords: cli,codex,mcp,plugin,scanner,security
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Security
22
+ Classifier: Topic :: Software Development :: Quality Assurance
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: rich>=13.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
27
+ Requires-Dist: pytest>=7.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.4.0; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # 🔗 Codex Plugin Scanner
32
+
33
+ [![PyPI version](https://img.shields.io/pypi/v/codex-plugin-scanner.svg)](https://pypi.org/project/codex-plugin-scanner/)
34
+ [![Python versions](https://img.shields.io/pypi/pyversions/codex-plugin-scanner.svg)](https://pypi.org/project/codex-plugin-scanner/)
35
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
36
+ [![CI](https://github.com/hashgraph-online/codex-plugin-scanner/actions/workflows/ci.yml/badge.svg)](https://github.com/hashgraph-online/codex-plugin-scanner/actions/workflows/ci.yml)
37
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/hashgraph-online/codex-plugin-scanner/badge)](https://scorecard.dev/viewer/?uri=github.com/hashgraph-online/codex-plugin-scanner)
38
+
39
+ A security and best-practices scanner for [Codex CLI plugins](https://developers.openai.com/codex/plugins). Scans plugin directories and outputs a score from 0-100.
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ pip install codex-plugin-scanner
45
+ ```
46
+
47
+ Or run directly without installing:
48
+
49
+ ```bash
50
+ pipx run codex-plugin-scanner ./my-plugin
51
+ ```
52
+
53
+ ## Usage
54
+
55
+ ```bash
56
+ # Scan a plugin directory
57
+ codex-plugin-scanner ./my-plugin
58
+
59
+ # Output as JSON
60
+ codex-plugin-scanner ./my-plugin --json
61
+
62
+ # Write report to file
63
+ codex-plugin-scanner ./my-plugin --output report.json
64
+ ```
65
+
66
+ ### Example Output
67
+
68
+ ```
69
+ 🔗 Codex Plugin Scanner v1.0.0
70
+ Scanning: ./my-plugin
71
+
72
+ ── Manifest Validation (25/25) ──
73
+ ✅ plugin.json exists +5
74
+ ✅ Valid JSON +5
75
+ ✅ Required fields present +8
76
+ ✅ Version follows semver +4
77
+ ✅ Name is kebab-case +3
78
+
79
+ ── Security (30/30) ──
80
+ ✅ SECURITY.md found +5
81
+ ✅ LICENSE found (Apache-2.0) +5
82
+ ✅ No hardcoded secrets detected +10
83
+ ✅ No dangerous MCP commands +10
84
+
85
+ ── Best Practices (25/25) ──
86
+ ✅ README.md found +5
87
+ ✅ Skills directory exists if declared +5
88
+ ✅ SKILL.md frontmatter +5
89
+ ✅ No .env files committed +5
90
+ ✅ .codexignore found +5
91
+
92
+ ── Marketplace (10/10) ──
93
+ ✅ marketplace.json valid +5
94
+ ✅ Policy fields present +5
95
+
96
+ ── Code Quality (10/10) ──
97
+ ✅ No eval or Function constructor +5
98
+ ✅ No shell injection patterns +5
99
+
100
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
101
+ Final Score: 100/100 (A - Excellent)
102
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
103
+ ```
104
+
105
+ ## Scoring Breakdown
106
+
107
+ | Category | Max Points | Checks |
108
+ |----------|-----------|--------|
109
+ | Manifest Validation | 25 | plugin.json exists, valid JSON, required fields, semver version, kebab-case name |
110
+ | Security | 30 | SECURITY.md, LICENSE, no hardcoded secrets, no dangerous MCP commands |
111
+ | Best Practices | 25 | README.md, skills directory, SKILL.md frontmatter, no .env files, .codexignore |
112
+ | Marketplace | 10 | marketplace.json valid, policy fields present |
113
+ | Code Quality | 10 | no eval/Function, no shell injection |
114
+
115
+ ### Grade Scale
116
+
117
+ | Score | Grade | Meaning |
118
+ |-------|-------|---------|
119
+ | 90-100 | A | Excellent |
120
+ | 80-89 | B | Good |
121
+ | 70-79 | C | Acceptable |
122
+ | 60-69 | D | Needs Improvement |
123
+ | 0-59 | F | Failing |
124
+
125
+ ## Security Checks
126
+
127
+ The scanner detects:
128
+
129
+ - **Hardcoded secrets**: AWS keys, GitHub tokens, OpenAI keys, Slack tokens, GitLab tokens, generic password/secret/token patterns
130
+ - **Dangerous MCP commands**: `rm -rf`, `sudo`, `curl|sh`, `wget|sh`, `eval`, `exec`, `powershell -c`
131
+ - **Shell injection**: template literals with unsanitized interpolation in exec/spawn calls
132
+ - **Unsafe code**: `eval()` and `new Function()` usage
133
+
134
+ ## Use as a GitHub Action
135
+
136
+ Add to your plugin's CI:
137
+
138
+ ```yaml
139
+ - name: Install scanner
140
+ run: pip install codex-plugin-scanner
141
+ - name: Scan plugin
142
+ run: codex-plugin-scanner ./my-plugin
143
+ ```
144
+
145
+ ## Use as a pre-commit hook
146
+
147
+ ```yaml
148
+ repos:
149
+ - repo: local
150
+ hooks:
151
+ - id: codex-plugin-scanner
152
+ name: Codex Plugin Scanner
153
+ entry: codex-plugin-scanner
154
+ language: system
155
+ types: [directory]
156
+ pass_filenames: false
157
+ args: ["./"]
158
+ ```
159
+
160
+ ## Development
161
+
162
+ ```bash
163
+ pip install -e ".[dev]"
164
+ pytest
165
+ ruff check src/
166
+ ```
167
+
168
+ ## Contributing
169
+
170
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
171
+
172
+ ## License
173
+
174
+ [Apache-2.0](LICENSE) - Hashgraph Online
@@ -0,0 +1,15 @@
1
+ codex_plugin_scanner/__init__.py,sha256=i2n3XpdZTRz03Q8m-XMzEiVjTa8ztagE-KNOK2xHEtg,359
2
+ codex_plugin_scanner/cli.py,sha256=JnoliIK98ObwSCXEUQDb7N9SObTmuEzFPicpu_4T_1g,5268
3
+ codex_plugin_scanner/models.py,sha256=f9LB9Jfozt8nLThh2_zSNk1SJ7NO4TlfOTUDIvXdOYE,1099
4
+ codex_plugin_scanner/scanner.py,sha256=PwtA1G6MTDiKEoNB-5hXbagPY6jl1j2OVdBQMrYCsto,1593
5
+ codex_plugin_scanner/checks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ codex_plugin_scanner/checks/best_practices.py,sha256=krGlH0LKglIkeDpAppKkWNnJ0akCwIU6RlEO4_prb_E,5095
7
+ codex_plugin_scanner/checks/code_quality.py,sha256=HqxM5O0vF0cddZMKm-IxZc6hLe9aEr3-D9HxMZgFMoI,2790
8
+ codex_plugin_scanner/checks/manifest.py,sha256=XamGZHndQWXEtY5pu6pHzn65GGLqU6X3UoclRweTTtc,4032
9
+ codex_plugin_scanner/checks/marketplace.py,sha256=KM9dATcmbel_OlBaV3YTYvPJE76xZYftkQ-rOjDtX-Y,4157
10
+ codex_plugin_scanner/checks/security.py,sha256=odWrNV-IFP_LDwZeLP_vuuKhoiLhdQh3SAlF2evXEA8,6137
11
+ codex_plugin_scanner-1.0.0.dist-info/METADATA,sha256=ltdI7OiZsQh1UI9UCPMAcLUpKOtit-4Z6FV-rCG7a34,5931
12
+ codex_plugin_scanner-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
+ codex_plugin_scanner-1.0.0.dist-info/entry_points.txt,sha256=Rk3DayqmbOKDsNT29p8sWSUM9rvGD1XGYTPtWwLoTHY,71
14
+ codex_plugin_scanner-1.0.0.dist-info/licenses/LICENSE,sha256=h2qHK0hIjysdHOFg3u6YBgFFJE3Wh2moOSfgVUFwdtU,5651
15
+ codex_plugin_scanner-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ codex-plugin-scanner = codex_plugin_scanner.cli:main
@@ -0,0 +1,118 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity.
18
+
19
+ "You" (or "Your") shall mean an individual or Legal Entity
20
+ exercising permissions granted by this License.
21
+
22
+ "Source" form shall mean the preferred form for making modifications,
23
+ including but not limited to software source code, documentation
24
+ source, and configuration files.
25
+
26
+ "Object" form shall mean any form resulting from mechanical
27
+ transformation or translation of a Source form.
28
+
29
+ "Work" shall mean the work of authorship, whether in Source or
30
+ Object form.
31
+
32
+ "Derivative Works" shall mean any work that is based on the Work
33
+ and for which the editorial revisions, annotations, elaborations,
34
+ or other modifications represent, as a whole, an original work of
35
+ authorship.
36
+
37
+ "Contribution" shall mean any work of authorship, including the
38
+ original version of the Work and any modifications or additions.
39
+
40
+ "Contributor" shall mean Licensor and any individual or Legal Entity
41
+ on behalf of whom a Contribution has been received.
42
+
43
+ 2. Grant of Copyright License. Subject to the terms and conditions of
44
+ this License, each Contributor hereby grants to You a perpetual,
45
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
46
+ copyright license to reproduce, prepare Derivative Works of,
47
+ publicly display, publicly perform, sublicense, and distribute the
48
+ Work and such Derivative Works in Source or Object form.
49
+
50
+ 3. Grant of Patent License. Subject to the terms and conditions of
51
+ this License, each Contributor hereby grants to You a perpetual,
52
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
53
+ (except as stated in this section) patent license to make, have made,
54
+ use, offer to sell, sell, import, and otherwise transfer the Work,
55
+ where such license applies only to those patent claims licensable
56
+ by such Contributor that are necessarily infringed by their
57
+ Contribution(s) alone or by combination of their Contribution(s)
58
+ with the Work to which such Contribution(s) was submitted.
59
+
60
+ 4. Redistribution. You may reproduce and distribute copies of the
61
+ Work or Derivative Works thereof in any medium, with or without
62
+ modifications, and in Source or Object form, provided that You
63
+ meet the following conditions:
64
+
65
+ (a) You must give any other recipients of the Work or
66
+ Derivative Works a copy of this License; and
67
+
68
+ (b) You must cause any modified files to carry prominent notices
69
+ stating that You changed the files; and
70
+
71
+ (c) You must retain, in the Source form of any Derivative Works
72
+ that You distribute, all copyright, patent, trademark, and
73
+ attribution notices from the Source form of the Work; and
74
+
75
+ (d) If the Work includes a "NOTICE" text file as part of its
76
+ distribution, then any Derivative Works that You distribute must
77
+ include a readable copy of the attribution notices contained
78
+ within such NOTICE file.
79
+
80
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
81
+ any Contribution intentionally submitted for inclusion in the Work
82
+ by You to the Licensor shall be under the terms and conditions of
83
+ this License, without any additional terms or conditions.
84
+
85
+ 6. Trademarks. This License does not grant permission to use the trade
86
+ names, trademarks, service marks, or product names of the Licensor,
87
+ except as required for reasonable and customary use in describing the
88
+ origin of the Work and reproducing the content of the NOTICE file.
89
+
90
+ 7. Disclaimer of Warranty. Unless required by applicable law or
91
+ agreed to in writing, Licensor provides the Work (and each
92
+ Contributor provides its Contributions) on an "AS IS" BASIS,
93
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
94
+ implied, including, without limitation, any warranties or conditions
95
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
96
+ PARTICULAR PURPOSE.
97
+
98
+ 8. Limitation of Liability. In no event and under no legal theory,
99
+ whether in tort (including negligence), contract, or otherwise,
100
+ unless required by applicable law, Contributor be liable for any
101
+ damages, including any direct, indirect, special, incidental, or
102
+ consequential damages.
103
+
104
+ 9. Accepting Warranty or Additional Liability. While redistributing
105
+ the Work or Derivative Works thereof, You may choose to offer,
106
+ and charge a fee for, acceptance of support, warranty, indemnity,
107
+ or other liability obligations and/or rights consistent with this
108
+ License.
109
+
110
+ END OF TERMS AND CONDITIONS
111
+
112
+ Copyright 2026 Hashgraph Online
113
+
114
+ Licensed under the Apache License, Version 2.0 (the "License");
115
+ you may not use this file except in compliance with the License.
116
+ You may obtain a copy of the License at
117
+
118
+ http://www.apache.org/licenses/LICENSE-2.0