macverify 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.
- macverify/__init__.py +39 -0
- macverify/__main__.py +4 -0
- macverify/aicommon.py +112 -0
- macverify/cli.py +231 -0
- macverify/collectors/__init__.py +0 -0
- macverify/collectors/ai_assistants.py +208 -0
- macverify/collectors/claude_code.py +926 -0
- macverify/collectors/containers.py +154 -0
- macverify/collectors/github_copilot.py +343 -0
- macverify/collectors/hardware.py +309 -0
- macverify/collectors/identity.py +270 -0
- macverify/collectors/network.py +298 -0
- macverify/collectors/openai_codex.py +411 -0
- macverify/collectors/packages.py +305 -0
- macverify/collectors/permissions.py +163 -0
- macverify/collectors/secrets.py +302 -0
- macverify/collectors/security.py +284 -0
- macverify/collectors/services.py +181 -0
- macverify/collectors/shell_env.py +354 -0
- macverify/collectors/storage.py +263 -0
- macverify/collectors/toolchain.py +474 -0
- macverify/compat.py +201 -0
- macverify/context.py +19 -0
- macverify/findings.py +38 -0
- macverify/fsutil.py +216 -0
- macverify/i18n.py +244 -0
- macverify/quickfix.py +280 -0
- macverify/registry.py +40 -0
- macverify/report_html.py +1422 -0
- macverify/report_md.py +175 -0
- macverify/runner.py +52 -0
- macverify/scope.py +150 -0
- macverify/shell.py +136 -0
- macverify/sysinfo.py +128 -0
- macverify-1.0.0.dist-info/METADATA +198 -0
- macverify-1.0.0.dist-info/RECORD +40 -0
- macverify-1.0.0.dist-info/WHEEL +5 -0
- macverify-1.0.0.dist-info/entry_points.txt +2 -0
- macverify-1.0.0.dist-info/licenses/LICENSE +21 -0
- macverify-1.0.0.dist-info/top_level.txt +1 -0
macverify/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
DISTRIBUTION = "macverify"
|
|
5
|
+
|
|
6
|
+
UNKNOWN_VERSION = "0+unknown"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _installed_version():
|
|
10
|
+
try:
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
except ImportError:
|
|
13
|
+
return None
|
|
14
|
+
try:
|
|
15
|
+
return version(DISTRIBUTION)
|
|
16
|
+
except PackageNotFoundError:
|
|
17
|
+
return None
|
|
18
|
+
except Exception:
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _source_version():
|
|
23
|
+
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "pyproject.toml")
|
|
24
|
+
try:
|
|
25
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
26
|
+
for line in handle:
|
|
27
|
+
match = re.match(r'^\s*version\s*=\s*["\']([^"\']+)["\']\s*$', line)
|
|
28
|
+
if match:
|
|
29
|
+
return match.group(1)
|
|
30
|
+
except (OSError, ValueError):
|
|
31
|
+
return None
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolve_version():
|
|
36
|
+
return _installed_version() or _source_version() or UNKNOWN_VERSION
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
__version__ = _resolve_version()
|
macverify/__main__.py
ADDED
macverify/aicommon.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from . import fsutil
|
|
5
|
+
|
|
6
|
+
STOPWORDS = {
|
|
7
|
+
"the", "a", "an", "and", "or", "for", "with", "when", "use", "using", "used", "this", "that", "to", "of", "in",
|
|
8
|
+
"on", "by", "is", "are", "be", "it", "its", "as", "at", "from", "into", "you", "your", "should", "can", "will",
|
|
9
|
+
"any", "all", "not", "but", "if", "then", "than", "also", "via", "per", "up", "out", "over", "before", "after",
|
|
10
|
+
"skill", "skills", "agent", "agents", "command", "commands", "claude", "code", "user", "users", "need", "needs",
|
|
11
|
+
"have", "has", "was", "were", "there", "their", "them", "they", "what", "which", "who", "how", "why", "more",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
NOT_APPLICABLE = "n/a"
|
|
15
|
+
|
|
16
|
+
MAX_ANCESTOR_DEPTH = 12
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def tokens(text):
|
|
20
|
+
words = re.findall(r"[a-z][a-z0-9_-]{2,}", (text or "").lower())
|
|
21
|
+
return {word for word in words if word not in STOPWORDS}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def similarity(left, right):
|
|
25
|
+
if not left or not right:
|
|
26
|
+
return 0.0, []
|
|
27
|
+
shared = left & right
|
|
28
|
+
union = left | right
|
|
29
|
+
if not union:
|
|
30
|
+
return 0.0, []
|
|
31
|
+
return round(len(shared) / float(len(union)), 3), sorted(shared)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cost(always_bytes, on_demand_bytes):
|
|
35
|
+
return {
|
|
36
|
+
"always_loaded_bytes": always_bytes,
|
|
37
|
+
"always_loaded_tokens_estimate": always_bytes // 4,
|
|
38
|
+
"on_demand_bytes": on_demand_bytes,
|
|
39
|
+
"on_demand_tokens_estimate": on_demand_bytes // 4,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def no_cost(reason):
|
|
44
|
+
return {
|
|
45
|
+
"always_loaded_bytes": None,
|
|
46
|
+
"always_loaded_tokens_estimate": NOT_APPLICABLE,
|
|
47
|
+
"on_demand_bytes": None,
|
|
48
|
+
"on_demand_tokens_estimate": NOT_APPLICABLE,
|
|
49
|
+
"measurement_note": reason,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def headings(body, limit=40):
|
|
54
|
+
outline = []
|
|
55
|
+
for line in (body or "").splitlines():
|
|
56
|
+
match = re.match(r"^(#{1,6})\s+(.+?)\s*#*$", line)
|
|
57
|
+
if match:
|
|
58
|
+
outline.append({"level": len(match.group(1)), "text": match.group(2)[:120]})
|
|
59
|
+
if len(outline) >= limit:
|
|
60
|
+
break
|
|
61
|
+
return outline
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def ancestors(start, home, include_home=True):
|
|
65
|
+
chain = []
|
|
66
|
+
current = os.path.abspath(start)
|
|
67
|
+
while True:
|
|
68
|
+
chain.append(current)
|
|
69
|
+
parent = os.path.dirname(current)
|
|
70
|
+
if parent == current or len(chain) >= MAX_ANCESTOR_DEPTH:
|
|
71
|
+
break
|
|
72
|
+
if current == home:
|
|
73
|
+
break
|
|
74
|
+
current = parent
|
|
75
|
+
if include_home and home not in chain and fsutil.exists(home):
|
|
76
|
+
chain.append(home)
|
|
77
|
+
return chain
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def project_roots(ctx):
|
|
81
|
+
roots = []
|
|
82
|
+
for root in [ctx.cwd] + list(ctx.projects):
|
|
83
|
+
absolute = os.path.abspath(os.path.expanduser(root))
|
|
84
|
+
if absolute not in roots:
|
|
85
|
+
roots.append(absolute)
|
|
86
|
+
return roots
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def search_roots(ctx):
|
|
90
|
+
home = fsutil.home()
|
|
91
|
+
seen = []
|
|
92
|
+
for root in project_roots(ctx):
|
|
93
|
+
for candidate in ancestors(root, home):
|
|
94
|
+
if candidate not in seen:
|
|
95
|
+
seen.append(candidate)
|
|
96
|
+
return seen
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def quote_lines(text, terms, limit=2, width=160):
|
|
100
|
+
if not text or not terms:
|
|
101
|
+
return []
|
|
102
|
+
wanted = set(terms)
|
|
103
|
+
picked = []
|
|
104
|
+
for line in text.splitlines():
|
|
105
|
+
stripped = line.strip().lstrip("#").strip()
|
|
106
|
+
if len(stripped) < 20:
|
|
107
|
+
continue
|
|
108
|
+
if tokens(stripped) & wanted:
|
|
109
|
+
picked.append(stripped[:width])
|
|
110
|
+
if len(picked) >= limit:
|
|
111
|
+
break
|
|
112
|
+
return picked
|
macverify/cli.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import datetime
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import __version__, compat, findings as findings_mod, quickfix, registry, report_html, report_md, runner, scope as scope_mod, sysinfo
|
|
8
|
+
from .context import Context
|
|
9
|
+
|
|
10
|
+
REPORT_DIR = os.path.join(os.path.expanduser("~"), ".macverify", "reports")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_parser():
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="macverify",
|
|
16
|
+
description="Read-only inventory of a macOS user space and its AI assistant configuration. Nothing is modified, elevated, or sent over the network.",
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument("--version", action="version", version="macverify %s" % __version__, help="print the installed version and exit")
|
|
19
|
+
parser.add_argument("--only", action="append", metavar="DOMAIN", help="run only this domain (repeatable)")
|
|
20
|
+
parser.add_argument("--skip", action="append", metavar="DOMAIN", help="skip this domain (repeatable)")
|
|
21
|
+
parser.add_argument("--json-only", action="store_true", help="write the JSON dataset only")
|
|
22
|
+
parser.add_argument("--html-only", action="store_true", help="write the HTML report only")
|
|
23
|
+
parser.add_argument("--out", metavar="DIR", default=REPORT_DIR, help="output directory (default: ~/.macverify/reports)")
|
|
24
|
+
parser.add_argument("--timeout", type=float, default=8.0, metavar="S", help="per-command timeout in seconds (default: 8)")
|
|
25
|
+
parser.add_argument("--lang", choices=("en", "es"), default="en", help="report label language (default: en)")
|
|
26
|
+
parser.add_argument("--project", action="append", metavar="PATH", help="extra project root to inspect for AI assistant config (repeatable)")
|
|
27
|
+
parser.add_argument("--verbose", action="store_true", help="print per-domain progress to stderr")
|
|
28
|
+
parser.add_argument("--list-domains", action="store_true", help="list domain names and exit")
|
|
29
|
+
parser.add_argument("--quick-fixes", action="store_true", help="print the quick-fix plan to stdout as well as writing the reports")
|
|
30
|
+
parser.add_argument("--check", action="store_true", help="report what this machine can be audited for, then exit without collecting")
|
|
31
|
+
return parser
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _log(enabled, message):
|
|
35
|
+
if enabled:
|
|
36
|
+
sys.stderr.write("[macverify] %s\n" % message)
|
|
37
|
+
sys.stderr.flush()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _collect_findings(results):
|
|
41
|
+
collected = []
|
|
42
|
+
for domain in sorted(results):
|
|
43
|
+
for item in results[domain].get("findings") or []:
|
|
44
|
+
if isinstance(item, dict) and item.get("id"):
|
|
45
|
+
collected.append(item)
|
|
46
|
+
return findings_mod.sort_findings(collected)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main(argv=None):
|
|
50
|
+
parser = build_parser()
|
|
51
|
+
args = parser.parse_args(argv)
|
|
52
|
+
|
|
53
|
+
if args.list_domains:
|
|
54
|
+
for domain in registry.DOMAINS:
|
|
55
|
+
sys.stdout.write("%s\n" % domain)
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
host = compat.preflight()
|
|
59
|
+
if args.check:
|
|
60
|
+
_print_check(host)
|
|
61
|
+
return 0 if host["supported"] else 1
|
|
62
|
+
if not host["supported"]:
|
|
63
|
+
sys.stderr.write("macverify cannot run here: %s\n" % host["reason"])
|
|
64
|
+
return 1
|
|
65
|
+
for warning in host["warnings"]:
|
|
66
|
+
_log(True, warning)
|
|
67
|
+
|
|
68
|
+
domains, unknown = registry.resolve(args.only, args.skip)
|
|
69
|
+
for name in unknown:
|
|
70
|
+
_log(True, "unknown domain ignored: %s" % name)
|
|
71
|
+
if not domains:
|
|
72
|
+
_log(True, "no domains selected")
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
projects = [os.path.abspath(os.path.expanduser(path)) for path in (args.project or [])]
|
|
76
|
+
ctx = Context(timeout=max(1.0, args.timeout), projects=projects, verbose=args.verbose, lang=args.lang)
|
|
77
|
+
|
|
78
|
+
started = datetime.datetime.now(datetime.timezone.utc)
|
|
79
|
+
_log(args.verbose, "running %d domains with a %.0fs per-command timeout" % (len(domains), ctx.timeout))
|
|
80
|
+
results = runner.run_all(domains, ctx)
|
|
81
|
+
elapsed = (datetime.datetime.now(datetime.timezone.utc) - started).total_seconds()
|
|
82
|
+
|
|
83
|
+
for domain in domains:
|
|
84
|
+
_log(args.verbose, "%-12s %s" % (domain, results[domain].get("status")))
|
|
85
|
+
|
|
86
|
+
all_findings = _collect_findings(results)
|
|
87
|
+
tally = findings_mod.counts(all_findings)
|
|
88
|
+
plan = quickfix.build(all_findings)
|
|
89
|
+
stamp = started.strftime("%Y%m%dT%H%M%SZ")
|
|
90
|
+
|
|
91
|
+
dataset = {
|
|
92
|
+
"schema_version": 1,
|
|
93
|
+
"tool": {"name": "macverify", "version": __version__, "mode": "read-only"},
|
|
94
|
+
"generated_at": started.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
95
|
+
"system": sysinfo.snapshot(),
|
|
96
|
+
"compatibility": host,
|
|
97
|
+
"scope": {
|
|
98
|
+
"reads": scope_mod.scope(args.lang)["does"],
|
|
99
|
+
"cannot_detect": scope_mod.scope(args.lang)["not"],
|
|
100
|
+
"recommended_scanners": scope_mod.scope(args.lang)["av_tools"],
|
|
101
|
+
},
|
|
102
|
+
"run": {
|
|
103
|
+
"domains": list(domains),
|
|
104
|
+
"per_command_timeout_seconds": ctx.timeout,
|
|
105
|
+
"language": args.lang,
|
|
106
|
+
"extra_projects": projects,
|
|
107
|
+
"statuses": {domain: results[domain].get("status") for domain in domains},
|
|
108
|
+
},
|
|
109
|
+
"summary": {
|
|
110
|
+
"finding_counts": tally,
|
|
111
|
+
"total_findings": len(all_findings),
|
|
112
|
+
"domains_ok": sorted(d for d in domains if results[d].get("status") == "ok"),
|
|
113
|
+
"domains_degraded": sorted(d for d in domains if results[d].get("status") not in ("ok",)),
|
|
114
|
+
},
|
|
115
|
+
"findings": all_findings,
|
|
116
|
+
"quick_fixes": plan,
|
|
117
|
+
"domains": {domain: results[domain] for domain in domains},
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
out_dir = os.path.abspath(os.path.expanduser(args.out))
|
|
121
|
+
try:
|
|
122
|
+
newly_created = not os.path.isdir(out_dir)
|
|
123
|
+
os.makedirs(out_dir, mode=0o700, exist_ok=True)
|
|
124
|
+
if newly_created or out_dir == os.path.abspath(REPORT_DIR):
|
|
125
|
+
os.chmod(out_dir, 0o700)
|
|
126
|
+
if out_dir == os.path.abspath(REPORT_DIR):
|
|
127
|
+
os.chmod(os.path.dirname(out_dir), 0o700)
|
|
128
|
+
except OSError as exc:
|
|
129
|
+
sys.stderr.write("cannot create output directory %s: %s\n" % (out_dir, exc))
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
written = []
|
|
133
|
+
if not args.html_only:
|
|
134
|
+
json_path = os.path.join(out_dir, "audit_%s.json" % stamp)
|
|
135
|
+
_write(json_path, json.dumps(dataset, indent=2, ensure_ascii=False, default=str) + "\n")
|
|
136
|
+
written.append(json_path)
|
|
137
|
+
|
|
138
|
+
if not args.json_only:
|
|
139
|
+
html_path = os.path.join(out_dir, "audit_%s.html" % stamp)
|
|
140
|
+
_write(html_path, report_html.render(dataset, args.lang))
|
|
141
|
+
written.append(html_path)
|
|
142
|
+
|
|
143
|
+
md_path = os.path.join(out_dir, "remediation.md")
|
|
144
|
+
_write(md_path, report_md.render(dataset, args.lang))
|
|
145
|
+
written.append(md_path)
|
|
146
|
+
|
|
147
|
+
assistants = {name: results[name] for name in registry.AI_ASSISTANT_DOMAINS if name in results}
|
|
148
|
+
if assistants and not args.html_only:
|
|
149
|
+
assistant_path = os.path.join(out_dir, "ai_assistant_findings.json")
|
|
150
|
+
payload = {
|
|
151
|
+
"generated_at": dataset["generated_at"],
|
|
152
|
+
"tool": dataset["tool"],
|
|
153
|
+
"domains": assistants,
|
|
154
|
+
"statuses": {name: assistants[name].get("status") for name in sorted(assistants)},
|
|
155
|
+
"findings": [item for item in all_findings if item.get("domain") in registry.AI_ASSISTANT_DOMAINS],
|
|
156
|
+
}
|
|
157
|
+
_write(assistant_path, json.dumps(payload, indent=2, ensure_ascii=False, default=str) + "\n")
|
|
158
|
+
written.append(assistant_path)
|
|
159
|
+
|
|
160
|
+
if args.quick_fixes:
|
|
161
|
+
_print_quick_fixes(plan)
|
|
162
|
+
|
|
163
|
+
sys.stdout.write("macverify %s read-only offline\n" % __version__)
|
|
164
|
+
sys.stdout.write("domains: %d ok, %d degraded findings: %d critical, %d warning, %d info (%.1fs)\n" % (
|
|
165
|
+
len(dataset["summary"]["domains_ok"]),
|
|
166
|
+
len(dataset["summary"]["domains_degraded"]),
|
|
167
|
+
tally["critical"],
|
|
168
|
+
tally["warning"],
|
|
169
|
+
tally["info"],
|
|
170
|
+
elapsed,
|
|
171
|
+
))
|
|
172
|
+
sys.stdout.write("quick fixes: %d commands (%d read-only, %d reversible, %d need care), %d manual steps\n" % (
|
|
173
|
+
plan["counts"]["commands"], plan["counts"]["inspect"], plan["counts"]["apply"],
|
|
174
|
+
plan["counts"]["careful"], plan["counts"]["manual_steps"]))
|
|
175
|
+
sys.stdout.write("this audit reads configuration only; it cannot detect malware. Run an anti-malware scan as well - see the Quick fixes tab.\n")
|
|
176
|
+
for path in written:
|
|
177
|
+
sys.stdout.write("wrote %s\n" % path)
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _print_check(host):
|
|
182
|
+
sys.stdout.write("platform %s\n" % host["platform"])
|
|
183
|
+
sys.stdout.write("macOS %s (build %s)\n" % (host["macos"]["version"], host["macos"]["build"]))
|
|
184
|
+
sys.stdout.write("architecture %s%s\n" % (
|
|
185
|
+
host["architecture"].get("native_arch"),
|
|
186
|
+
" (interpreter translated by Rosetta)" if host["architecture"].get("running_under_rosetta") else ""))
|
|
187
|
+
sys.stdout.write("python %s\n" % host["python"]["version"])
|
|
188
|
+
sys.stdout.write("account %s%s\n" % (
|
|
189
|
+
host["account"].get("user"),
|
|
190
|
+
" (admin)" if host["account"].get("admin") else " (standard)" if host["account"].get("admin") is False else ""))
|
|
191
|
+
sys.stdout.write("supported %s\n" % ("yes" if host["supported"] else "no: %s" % host["reason"]))
|
|
192
|
+
for warning in host["warnings"]:
|
|
193
|
+
sys.stdout.write("warning %s\n" % warning)
|
|
194
|
+
for note in host["capability_notes"]:
|
|
195
|
+
sys.stdout.write("%-13s %s: %s\n" % (note["expect"], note["domain"], note["detail"]))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _print_quick_fixes(plan):
|
|
199
|
+
headings = (
|
|
200
|
+
("inspect", "look first, read-only"),
|
|
201
|
+
("apply", "safe to apply, reversible"),
|
|
202
|
+
("careful", "read before running"),
|
|
203
|
+
)
|
|
204
|
+
for tier, heading in headings:
|
|
205
|
+
items = quickfix.by_tier(plan, tier)
|
|
206
|
+
if not items:
|
|
207
|
+
continue
|
|
208
|
+
sys.stdout.write("\n# %s\n" % heading)
|
|
209
|
+
for entry in items:
|
|
210
|
+
sys.stdout.write("# %s\n" % "; ".join(entry["titles"][:2]))
|
|
211
|
+
sys.stdout.write("%s\n" % entry["command"])
|
|
212
|
+
if plan["manual_steps"]:
|
|
213
|
+
sys.stdout.write("\n# steps with no command\n")
|
|
214
|
+
for entry in plan["manual_steps"]:
|
|
215
|
+
sys.stdout.write("# %s -> %s\n" % ("; ".join(entry["titles"][:2]), entry["manual_step"]))
|
|
216
|
+
sys.stdout.write("\n")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _write(path, text):
|
|
220
|
+
try:
|
|
221
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
222
|
+
try:
|
|
223
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
224
|
+
descriptor = None
|
|
225
|
+
handle.write(text)
|
|
226
|
+
finally:
|
|
227
|
+
if descriptor is not None:
|
|
228
|
+
os.close(descriptor)
|
|
229
|
+
os.chmod(path, 0o600)
|
|
230
|
+
except OSError as exc:
|
|
231
|
+
sys.stderr.write("cannot write %s: %s\n" % (path, exc))
|
|
File without changes
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from .. import aicommon
|
|
4
|
+
from .. import findings as F
|
|
5
|
+
from .. import fsutil
|
|
6
|
+
from ..context import default_context
|
|
7
|
+
|
|
8
|
+
INSTRUCTION_FILES = (
|
|
9
|
+
("claude_code", "CLAUDE.md", "CLAUDE.md"),
|
|
10
|
+
("openai_codex", "AGENTS.md", "AGENTS.md"),
|
|
11
|
+
("github_copilot", os.path.join(".github", "copilot-instructions.md"), ".github/copilot-instructions.md"),
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
OVERLAP_SCORE = 0.18
|
|
15
|
+
|
|
16
|
+
OVERLAP_MINIMUM_TERMS = 6
|
|
17
|
+
|
|
18
|
+
STRONG_OVERLAP_SCORE = 0.4
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _entry(tool, label, path, directory, active, active_reason):
|
|
22
|
+
text = fsutil.read_text(path) or ""
|
|
23
|
+
size = len(text.encode("utf-8"))
|
|
24
|
+
return {
|
|
25
|
+
"tool": tool,
|
|
26
|
+
"label": label,
|
|
27
|
+
"path": fsutil.tilde(path),
|
|
28
|
+
"directory": fsutil.tilde(directory),
|
|
29
|
+
"file_bytes": size,
|
|
30
|
+
"line_count": len(text.splitlines()),
|
|
31
|
+
"empty": not text.strip(),
|
|
32
|
+
"active": active,
|
|
33
|
+
"active_reason": active_reason,
|
|
34
|
+
"heading_outline": aicommon.headings(text, limit=12),
|
|
35
|
+
"context_cost": aicommon.cost(size if active else 0, 0 if active else size),
|
|
36
|
+
"_tokens": aicommon.tokens(text),
|
|
37
|
+
"_text": text,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def collect(ctx=None):
|
|
42
|
+
ctx = default_context(ctx)
|
|
43
|
+
home = fsutil.home()
|
|
44
|
+
cwd = os.path.abspath(ctx.cwd)
|
|
45
|
+
roots = aicommon.project_roots(ctx)
|
|
46
|
+
|
|
47
|
+
entries = []
|
|
48
|
+
seen = set()
|
|
49
|
+
for root in roots:
|
|
50
|
+
for directory in aicommon.ancestors(root, home):
|
|
51
|
+
for tool, relative, label in INSTRUCTION_FILES:
|
|
52
|
+
path = os.path.join(directory, relative)
|
|
53
|
+
if path in seen or not fsutil.exists(path):
|
|
54
|
+
continue
|
|
55
|
+
seen.add(path)
|
|
56
|
+
if directory == cwd:
|
|
57
|
+
active, reason = True, "in the current directory"
|
|
58
|
+
elif cwd.startswith(directory.rstrip(os.sep) + os.sep):
|
|
59
|
+
active, reason = True, "ancestor of the current directory"
|
|
60
|
+
else:
|
|
61
|
+
active, reason = False, "belongs to %s, which is not the current directory" % fsutil.tilde(directory)
|
|
62
|
+
entries.append(_entry(tool, label, path, directory, active, reason))
|
|
63
|
+
|
|
64
|
+
if not entries:
|
|
65
|
+
return {
|
|
66
|
+
"status": "unavailable",
|
|
67
|
+
"reason": "no CLAUDE.md, AGENTS.md or .github/copilot-instructions.md found in the inspected project roots or their ancestors",
|
|
68
|
+
"inventory": {},
|
|
69
|
+
"analysis": {},
|
|
70
|
+
"findings": [],
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
by_directory = {}
|
|
74
|
+
for entry in entries:
|
|
75
|
+
by_directory.setdefault(entry["directory"], []).append(entry)
|
|
76
|
+
|
|
77
|
+
overlaps = []
|
|
78
|
+
for directory in sorted(by_directory):
|
|
79
|
+
group = by_directory[directory]
|
|
80
|
+
if len(group) < 2:
|
|
81
|
+
continue
|
|
82
|
+
for index in range(len(group)):
|
|
83
|
+
for other_index in range(index + 1, len(group)):
|
|
84
|
+
left = group[index]
|
|
85
|
+
right = group[other_index]
|
|
86
|
+
score, shared = aicommon.similarity(left["_tokens"], right["_tokens"])
|
|
87
|
+
union = len(left["_tokens"] | right["_tokens"])
|
|
88
|
+
overlaps.append({
|
|
89
|
+
"directory": directory,
|
|
90
|
+
"left": {
|
|
91
|
+
"tool": left["tool"],
|
|
92
|
+
"file": left["label"],
|
|
93
|
+
"path": left["path"],
|
|
94
|
+
"bytes": left["file_bytes"],
|
|
95
|
+
"quoted_lines": aicommon.quote_lines(left["_text"], shared) or aicommon.quote_lines(left["_text"], left["_tokens"], limit=1),
|
|
96
|
+
},
|
|
97
|
+
"right": {
|
|
98
|
+
"tool": right["tool"],
|
|
99
|
+
"file": right["label"],
|
|
100
|
+
"path": right["path"],
|
|
101
|
+
"bytes": right["file_bytes"],
|
|
102
|
+
"quoted_lines": aicommon.quote_lines(right["_text"], shared) or aicommon.quote_lines(right["_text"], right["_tokens"], limit=1),
|
|
103
|
+
},
|
|
104
|
+
"score": score,
|
|
105
|
+
"shared_terms": shared[:16],
|
|
106
|
+
"shared_term_count": len(shared),
|
|
107
|
+
"above_reporting_threshold": score >= OVERLAP_SCORE and len(shared) >= OVERLAP_MINIMUM_TERMS,
|
|
108
|
+
"evidence": "%d of %d combined domain terms are shared (%s vocabulary overlap)" % (
|
|
109
|
+
len(shared), union, "{:.0%}".format(score)),
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
multi_tool_directories = []
|
|
113
|
+
for directory in sorted(by_directory):
|
|
114
|
+
group = by_directory[directory]
|
|
115
|
+
tools = sorted({entry["tool"] for entry in group})
|
|
116
|
+
if len(tools) < 2:
|
|
117
|
+
continue
|
|
118
|
+
pairs = [item for item in overlaps if item["directory"] == directory]
|
|
119
|
+
multi_tool_directories.append({
|
|
120
|
+
"directory": directory,
|
|
121
|
+
"tools": tools,
|
|
122
|
+
"files": [{"tool": entry["tool"], "file": entry["label"], "path": entry["path"], "bytes": entry["file_bytes"], "active": entry["active"], "empty": entry["empty"]} for entry in group],
|
|
123
|
+
"total_bytes": sum(entry["file_bytes"] for entry in group),
|
|
124
|
+
"active_bytes": sum(entry["file_bytes"] for entry in group if entry["active"]),
|
|
125
|
+
"pairwise_similarity": [
|
|
126
|
+
{"files": "%s vs %s" % (item["left"]["file"], item["right"]["file"]), "score": item["score"],
|
|
127
|
+
"shared_term_count": item["shared_term_count"], "above_reporting_threshold": item["above_reporting_threshold"]}
|
|
128
|
+
for item in sorted(pairs, key=lambda entry: -entry["score"])
|
|
129
|
+
],
|
|
130
|
+
"highest_score": max([item["score"] for item in pairs] or [0.0]),
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
tools_present = sorted({entry["tool"] for entry in entries})
|
|
134
|
+
active_entries = [entry for entry in entries if entry["active"]]
|
|
135
|
+
per_tool = {}
|
|
136
|
+
for entry in entries:
|
|
137
|
+
bucket = per_tool.setdefault(entry["tool"], {"files": 0, "active": 0, "bytes": 0, "active_bytes": 0})
|
|
138
|
+
bucket["files"] += 1
|
|
139
|
+
bucket["bytes"] += entry["file_bytes"]
|
|
140
|
+
if entry["active"]:
|
|
141
|
+
bucket["active"] += 1
|
|
142
|
+
bucket["active_bytes"] += entry["file_bytes"]
|
|
143
|
+
for bucket in per_tool.values():
|
|
144
|
+
bucket["active_tokens_estimate"] = bucket["active_bytes"] // 4
|
|
145
|
+
|
|
146
|
+
public = []
|
|
147
|
+
for entry in entries:
|
|
148
|
+
clean = {key: value for key, value in entry.items() if not key.startswith("_")}
|
|
149
|
+
public.append(clean)
|
|
150
|
+
|
|
151
|
+
inventory = {
|
|
152
|
+
"tools_with_instruction_files": tools_present,
|
|
153
|
+
"instruction_files": sorted(public, key=lambda item: (item["directory"], item["label"])),
|
|
154
|
+
"directories_with_more_than_one_tool": multi_tool_directories,
|
|
155
|
+
"per_tool": dict(sorted(per_tool.items())),
|
|
156
|
+
"project_roots_inspected": [fsutil.tilde(root) for root in roots],
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
analysis = {
|
|
160
|
+
"instruction_file_count": len(entries),
|
|
161
|
+
"active_instruction_file_count": len(active_entries),
|
|
162
|
+
"combined_active_bytes": sum(entry["file_bytes"] for entry in active_entries),
|
|
163
|
+
"combined_active_tokens_estimate": sum(entry["file_bytes"] for entry in active_entries) // 4,
|
|
164
|
+
"instruction_file_overlaps": overlaps,
|
|
165
|
+
"method": "two instruction files in the same directory are compared by the Jaccard overlap of their content vocabularies after stopword removal; a finding is only raised when at least %d domain terms are shared, and every finding carries lines quoted from both files" % OVERLAP_MINIMUM_TERMS,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
findings = []
|
|
169
|
+
for entry in multi_tool_directories:
|
|
170
|
+
pairs = [item for item in overlaps if item["directory"] == entry["directory"]]
|
|
171
|
+
top = max(pairs, key=lambda item: item["score"]) if pairs else None
|
|
172
|
+
crossing = [item for item in pairs if item["above_reporting_threshold"]]
|
|
173
|
+
basis = []
|
|
174
|
+
if top:
|
|
175
|
+
for side in ("left", "right"):
|
|
176
|
+
quotes = top[side]["quoted_lines"]
|
|
177
|
+
if quotes:
|
|
178
|
+
basis.append('%s says "%s"' % (top[side]["file"], quotes[0]))
|
|
179
|
+
scores = "; ".join("%s %s" % (item["files"], "{:.0%}".format(item["score"])) for item in entry["pairwise_similarity"])
|
|
180
|
+
if crossing:
|
|
181
|
+
severity = "warning" if entry["highest_score"] >= STRONG_OVERLAP_SCORE else "info"
|
|
182
|
+
matters = "The same conventions maintained in two instruction files are loaded separately by each assistant and drift apart, so the two tools end up working to different versions of the same rule."
|
|
183
|
+
action = "Keep the shared rules in one file and have the others point at it, or split them so each file states only what is specific to its assistant"
|
|
184
|
+
else:
|
|
185
|
+
severity = "info"
|
|
186
|
+
matters = "Separate instruction files per assistant are only a problem when they drift; here they describe different things, so the cost is maintaining several files rather than contradicting guidance."
|
|
187
|
+
action = "Decide which file is authoritative for shared conventions, and have the others reference it rather than restate it"
|
|
188
|
+
findings.append(F.finding(
|
|
189
|
+
"ai_assistants",
|
|
190
|
+
severity,
|
|
191
|
+
"%s carries instruction files for %d assistants" % (entry["directory"], len(entry["tools"])),
|
|
192
|
+
"%s. Vocabulary overlap: %s. %s" % (
|
|
193
|
+
", ".join("%s (%s, %s)" % (item["file"], item["tool"], fsutil.human_bytes(item["bytes"])) for item in entry["files"]),
|
|
194
|
+
scores or "not comparable",
|
|
195
|
+
" ".join(basis) if basis else "no quotable content in either file",
|
|
196
|
+
),
|
|
197
|
+
matters,
|
|
198
|
+
action,
|
|
199
|
+
True,
|
|
200
|
+
key="multi-tool-%s" % entry["directory"],
|
|
201
|
+
))
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
"status": "ok",
|
|
205
|
+
"inventory": inventory,
|
|
206
|
+
"analysis": analysis,
|
|
207
|
+
"findings": findings,
|
|
208
|
+
}
|