every-app-is-open-source 0.1.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.
- eaos/__init__.py +3 -0
- eaos/backend.py +246 -0
- eaos/calculator.py +50 -0
- eaos/cli.py +97 -0
- eaos/distributions.json +10 -0
- eaos/evidence.py +47 -0
- eaos/runtime.py +189 -0
- eaos/swift.py +20 -0
- every_app_is_open_source-0.1.0.dist-info/METADATA +151 -0
- every_app_is_open_source-0.1.0.dist-info/RECORD +15 -0
- every_app_is_open_source-0.1.0.dist-info/WHEEL +5 -0
- every_app_is_open_source-0.1.0.dist-info/entry_points.txt +2 -0
- every_app_is_open_source-0.1.0.dist-info/licenses/LICENSE +202 -0
- every_app_is_open_source-0.1.0.dist-info/licenses/NOTICE +7 -0
- every_app_is_open_source-0.1.0.dist-info/top_level.txt +1 -0
eaos/__init__.py
ADDED
eaos/backend.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Small PyGhidra adapter. A digest-keyed persistent project lives beside the binary."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import fcntl
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .evidence import Evidence
|
|
15
|
+
from .swift import decode_small_string
|
|
16
|
+
from .runtime import configure
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Backend:
|
|
20
|
+
def __init__(self, target: str):
|
|
21
|
+
self.target = Path(target).expanduser().resolve(strict=True)
|
|
22
|
+
if not self.target.is_file():
|
|
23
|
+
raise ValueError("Target must be a regular file")
|
|
24
|
+
self.digest = hashlib.sha256(self.target.read_bytes()).hexdigest()
|
|
25
|
+
self.directory = self.target.parent / 'eaos-analysis' / self.digest
|
|
26
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
self.evidence = Evidence(self.directory / 'evidence', self.digest)
|
|
28
|
+
self.program = self.project = self.consumer = self.decompiler = None
|
|
29
|
+
self.lock = None
|
|
30
|
+
|
|
31
|
+
def _load(self):
|
|
32
|
+
if self.program is not None:
|
|
33
|
+
return
|
|
34
|
+
self.lock = (self.directory / 'session.lock').open('a+')
|
|
35
|
+
try:
|
|
36
|
+
fcntl.flock(self.lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
37
|
+
except BlockingIOError:
|
|
38
|
+
self.lock.close()
|
|
39
|
+
self.lock = None
|
|
40
|
+
raise RuntimeError("Program is already open in another EAOS process; close it before retrying")
|
|
41
|
+
tools, ghidra, jdk = configure()
|
|
42
|
+
from pyghidra.launcher import HeadlessPyGhidraLauncher
|
|
43
|
+
import pyghidra
|
|
44
|
+
if not pyghidra.started():
|
|
45
|
+
launcher = HeadlessPyGhidraLauncher(install_dir=ghidra)
|
|
46
|
+
state = tools / 'java-state'; state.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
launcher.add_vmargs('-Xmx4G', f'-Duser.home={state}')
|
|
48
|
+
launcher.start()
|
|
49
|
+
from ghidra.util.task import TaskMonitor
|
|
50
|
+
from ghidra.program.util import GhidraProgramUtilities
|
|
51
|
+
from ghidra.app.decompiler import DecompInterface
|
|
52
|
+
monitor = TaskMonitor.DUMMY
|
|
53
|
+
project_dir = self.directory / 'ghidra'; project_dir.mkdir(exist_ok=True)
|
|
54
|
+
self.project = pyghidra.open_project(project_dir, 'program', create=True)
|
|
55
|
+
started = time.monotonic()
|
|
56
|
+
imported = self.project.getProjectData().getFile('/'+self.target.name) is None
|
|
57
|
+
if imported:
|
|
58
|
+
results = pyghidra.program_loader().project(self.project).source(str(self.target)).name(self.target.name).load()
|
|
59
|
+
try:
|
|
60
|
+
results.getPrimary().save(monitor)
|
|
61
|
+
finally:
|
|
62
|
+
results.close()
|
|
63
|
+
self.program, self.consumer = pyghidra.consume_program(self.project, '/'+self.target.name)
|
|
64
|
+
analyzed = False
|
|
65
|
+
if imported or not GhidraProgramUtilities.isAnalyzed(self.program):
|
|
66
|
+
log = pyghidra.analyze(self.program)
|
|
67
|
+
(self.directory / 'analysis.log').write_text(str(log))
|
|
68
|
+
self.program.save('EAOS initial analysis', monitor)
|
|
69
|
+
analyzed = True
|
|
70
|
+
metadata_path = self.directory / 'analysis.json'
|
|
71
|
+
metadata = json.loads(metadata_path.read_text()) if metadata_path.exists() else {}
|
|
72
|
+
metadata.update(target=str(self.target), sha256=self.digest,
|
|
73
|
+
ghidra_version='12.1.4', language=str(self.program.getLanguageID()))
|
|
74
|
+
if analyzed:
|
|
75
|
+
metadata['analysis_count'] = metadata.get('analysis_count', 0) + 1
|
|
76
|
+
metadata['analysis_seconds'] = time.monotonic() - started
|
|
77
|
+
metadata_path.write_text(json.dumps(metadata, indent=2))
|
|
78
|
+
self.decompiler = DecompInterface()
|
|
79
|
+
self.decompiler.openProgram(self.program)
|
|
80
|
+
|
|
81
|
+
def close(self):
|
|
82
|
+
if self.decompiler is not None:
|
|
83
|
+
self.decompiler.dispose()
|
|
84
|
+
self.decompiler = None
|
|
85
|
+
if self.program is not None:
|
|
86
|
+
self.program.release(self.consumer)
|
|
87
|
+
self.program = None
|
|
88
|
+
if self.project is not None:
|
|
89
|
+
self.project.close()
|
|
90
|
+
self.project = None
|
|
91
|
+
if self.lock is not None:
|
|
92
|
+
fcntl.flock(self.lock, fcntl.LOCK_UN); self.lock.close(); self.lock = None
|
|
93
|
+
|
|
94
|
+
def result(self, operation, classification, data, limitations=None, preview=None):
|
|
95
|
+
evidence = self.evidence.save(operation, classification, data, limitations)
|
|
96
|
+
return {'classification': classification, 'data': data if preview is None else preview,
|
|
97
|
+
'limitations': limitations or [],
|
|
98
|
+
'evidence': {'id': evidence['id'], 'uri': evidence['uri']}}
|
|
99
|
+
|
|
100
|
+
def address(self, address):
|
|
101
|
+
result = self.program.getAddressFactory().getAddress(str(address))
|
|
102
|
+
if result is None:
|
|
103
|
+
raise ValueError('Invalid program address')
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
def function(self, address):
|
|
107
|
+
addr = self.address(address)
|
|
108
|
+
fn = self.program.getFunctionManager().getFunctionContaining(addr)
|
|
109
|
+
if fn is None:
|
|
110
|
+
raise ValueError('No analyzed function contains this address; inspect references or memory')
|
|
111
|
+
return fn
|
|
112
|
+
|
|
113
|
+
def fn_info(self, fn):
|
|
114
|
+
return {'address': str(fn.getEntryPoint()), 'name': str(fn.getName()),
|
|
115
|
+
'signature': str(fn.getSignature()), 'size': int(fn.getBody().getNumAddresses())}
|
|
116
|
+
|
|
117
|
+
def open_program(self, full=False):
|
|
118
|
+
self._load()
|
|
119
|
+
blocks = [{'name':str(b.getName()),'start':str(b.getStart()),'end':str(b.getEnd()),
|
|
120
|
+
'read':bool(b.isRead()),'write':bool(b.isWrite()),'execute':bool(b.isExecute())}
|
|
121
|
+
for b in self.program.getMemory().getBlocks()]
|
|
122
|
+
data = {'target': str(self.target), 'sha256': self.digest,
|
|
123
|
+
'language': str(self.program.getLanguageID()),
|
|
124
|
+
'function_count': int(self.program.getFunctionManager().getFunctionCount()),
|
|
125
|
+
'memory': blocks, 'cache':json.loads((self.directory/'analysis.json').read_text())}
|
|
126
|
+
preview = data if full else {k:data[k] for k in ('sha256','language','function_count')}
|
|
127
|
+
if not full:
|
|
128
|
+
preview['analysis_count'] = data['cache'].get('analysis_count')
|
|
129
|
+
preview['memory'] = [{'name':b['name'],'start':b['start'],'end':b['end'],
|
|
130
|
+
'permissions': ''.join(c if b[k] else '-' for c,k in [('r','read'),('w','write'),('x','execute')])} for b in blocks]
|
|
131
|
+
return self.result('open_program','computed',data,
|
|
132
|
+
['Recovered functions/types are computed, not original source.'], preview)
|
|
133
|
+
|
|
134
|
+
def search(self, kind='strings', pattern='', offset=0, limit=30, full=False):
|
|
135
|
+
self._load()
|
|
136
|
+
if not 0 <= offset or not 1 <= limit <= 200:
|
|
137
|
+
raise ValueError('offset must be nonnegative and limit 1..200')
|
|
138
|
+
pattern = pattern.casefold()
|
|
139
|
+
rows = []
|
|
140
|
+
if kind == 'functions':
|
|
141
|
+
for fn in self.program.getFunctionManager().getFunctions(True):
|
|
142
|
+
item = self.fn_info(fn)
|
|
143
|
+
if pattern in (item['name']+' '+item['signature']).casefold(): rows.append(item)
|
|
144
|
+
elif kind == 'strings':
|
|
145
|
+
from java.lang import String
|
|
146
|
+
for item in self.program.getListing().getDefinedData(True):
|
|
147
|
+
value = item.getValue()
|
|
148
|
+
if not isinstance(value, (str, String)): continue
|
|
149
|
+
text = str(value)
|
|
150
|
+
if pattern not in text.casefold(): continue
|
|
151
|
+
refs = list(self.program.getReferenceManager().getReferencesTo(item.getAddress()))
|
|
152
|
+
rows.append({'address':str(item.getAddress()),'text':text,
|
|
153
|
+
'references':[{'address':str(r.getFromAddress()),
|
|
154
|
+
'function':self.fn_info(fn) if (fn:=self.program.getFunctionManager().getFunctionContaining(r.getFromAddress())) else None}
|
|
155
|
+
for r in refs]})
|
|
156
|
+
else:
|
|
157
|
+
raise ValueError('kind must be strings or functions')
|
|
158
|
+
page = rows[offset:offset+limit]
|
|
159
|
+
data = {'kind':kind,'matches':len(rows),'items':page,
|
|
160
|
+
'next_offset':offset+limit if offset+limit<len(rows) else None}
|
|
161
|
+
preview = json.loads(json.dumps(data))
|
|
162
|
+
if not full:
|
|
163
|
+
for item in preview['items']:
|
|
164
|
+
item.pop('signature',None); item.pop('size',None)
|
|
165
|
+
for ref in item.get('references',[]):
|
|
166
|
+
if ref['function']:
|
|
167
|
+
ref['function'] = {'address':ref['function']['address']}
|
|
168
|
+
return self.result('search','computed',data,
|
|
169
|
+
['Only Ghidra-defined strings/functions; other text may be absent.'], preview)
|
|
170
|
+
|
|
171
|
+
def inspect(self, addresses, mode='decompile', full=False):
|
|
172
|
+
self._load()
|
|
173
|
+
if not 1 <= len(addresses) <= 8: raise ValueError('Supply 1..8 addresses')
|
|
174
|
+
if mode not in {'decompile','assembly','references','calls','all'}:
|
|
175
|
+
raise ValueError('mode must be decompile, assembly, references, calls or all')
|
|
176
|
+
from ghidra.util.task import TaskMonitor
|
|
177
|
+
rows = []
|
|
178
|
+
for address in addresses:
|
|
179
|
+
addr = self.address(address)
|
|
180
|
+
if mode == 'references':
|
|
181
|
+
refs = [{'from':str(r.getFromAddress()),'to':str(r.getToAddress()),
|
|
182
|
+
'type':str(r.getReferenceType()),
|
|
183
|
+
'function':self.fn_info(fn) if (fn:=self.program.getFunctionManager().getFunctionContaining(r.getFromAddress())) else None}
|
|
184
|
+
for r in self.program.getReferenceManager().getReferencesTo(addr)]
|
|
185
|
+
rows.append({'address':str(addr),'references':refs});continue
|
|
186
|
+
fn = self.function(address); row = self.fn_info(fn)
|
|
187
|
+
if mode in {'decompile','all'}:
|
|
188
|
+
result = self.decompiler.decompileFunction(fn, 60, TaskMonitor.DUMMY)
|
|
189
|
+
if not result.decompileCompleted(): raise RuntimeError(str(result.getErrorMessage()))
|
|
190
|
+
code = str(result.getDecompiledFunction().getC())
|
|
191
|
+
row['pseudocode'] = code
|
|
192
|
+
path = self.directory/'evidence'/f'function-{fn.getEntryPoint()}.c'
|
|
193
|
+
path.write_text(code)
|
|
194
|
+
if mode in {'assembly','all'}:
|
|
195
|
+
row['instructions'] = [{'address':str(i.getAddress()), 'text':str(i),
|
|
196
|
+
'bytes':bytes((int(b)&255) for b in i.getBytes()).hex()}
|
|
197
|
+
for i in self.program.getListing().getInstructions(fn.getBody(),True)]
|
|
198
|
+
if mode in {'calls','all'} or (mode=='decompile' and full):
|
|
199
|
+
row['callers'] = sorted([self.fn_info(f) for f in fn.getCallingFunctions(TaskMonitor.DUMMY)],key=lambda f:f['address'])
|
|
200
|
+
row['callees'] = sorted([self.fn_info(f) for f in fn.getCalledFunctions(TaskMonitor.DUMMY)],key=lambda f:f['address'])
|
|
201
|
+
rows.append(row)
|
|
202
|
+
preview = json.loads(json.dumps(rows))
|
|
203
|
+
for row in preview:
|
|
204
|
+
if 'pseudocode' in row:
|
|
205
|
+
cap = 32000 if full else 5000
|
|
206
|
+
row['truncated'] = len(row['pseudocode']) > cap
|
|
207
|
+
row['pseudocode'] = row['pseudocode'][:cap]
|
|
208
|
+
for field in ['instructions','references','callers','callees']:
|
|
209
|
+
if field in row:
|
|
210
|
+
count = 500 if full else (64 if field=='instructions' else 30)
|
|
211
|
+
row[field+'_total'] = len(row[field]);row[field] = row[field][:count]
|
|
212
|
+
return self.result('inspect','fact' if mode=='assembly' else 'computed',rows,
|
|
213
|
+
['Pseudocode, types and call graph are computed static interpretations; indirect calls may be unresolved.'] if mode!='assembly' else [], preview)
|
|
214
|
+
|
|
215
|
+
def read_memory(self, address, length=64, format='hex'):
|
|
216
|
+
self._load()
|
|
217
|
+
if format not in {'hex','utf8','both'}: raise ValueError('format must be hex, utf8 or both')
|
|
218
|
+
if not 1 <= length <= 65536: raise ValueError('length must be 1..65536')
|
|
219
|
+
import jpype
|
|
220
|
+
raw = jpype.JArray(jpype.JByte)(length)
|
|
221
|
+
count = self.program.getMemory().getBytes(self.address(address),raw)
|
|
222
|
+
data = bytes(int(x)&255 for x in raw[:count])
|
|
223
|
+
record = {'address':address,'length':count,'hex':data.hex(),
|
|
224
|
+
'utf8':data.decode('utf-8',errors='replace')}
|
|
225
|
+
preview = {k:v for k,v in record.items() if k not in {'hex','utf8'} or format=='both' or k==format}
|
|
226
|
+
return self.result('read_memory','fact',record,preview=preview)
|
|
227
|
+
|
|
228
|
+
def calculate(self, expression):
|
|
229
|
+
process = subprocess.run([sys.executable,'-I',str(Path(__file__).with_name('calculator.py'))],
|
|
230
|
+
input=json.dumps({'expression':expression}),capture_output=True,text=True,timeout=4)
|
|
231
|
+
if process.returncode: raise ValueError(process.stderr.strip())
|
|
232
|
+
return self.result('calculate','computed',{'expression':expression,'value':process.stdout.strip()},
|
|
233
|
+
['Arithmetic establishes the supplied expression, not whether its premises match the binary.'])
|
|
234
|
+
|
|
235
|
+
def decode_swift_string(self, words=None, address=None):
|
|
236
|
+
limitations = ['Supports Swift small String storage only; no pointer-backed or Objective-C tagged strings.']
|
|
237
|
+
if address is not None:
|
|
238
|
+
raw = bytes.fromhex(self.read_memory(address,16)['data']['hex'])
|
|
239
|
+
words = [int.from_bytes(raw[:8],'little'),int.from_bytes(raw[8:],'little')]
|
|
240
|
+
else:
|
|
241
|
+
limitations.append('Words were supplied by the caller; their presence in this binary is not established.')
|
|
242
|
+
if words is None: raise ValueError('Supply words or a program address')
|
|
243
|
+
return self.result('decode_swift_string','computed',decode_small_string(words),limitations)
|
|
244
|
+
|
|
245
|
+
def read_evidence(self, id, offset=0, limit=12000):
|
|
246
|
+
return self.evidence.read(id,offset,limit)
|
eaos/calculator.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Pure bounded calculator. Executed in a time-limited child process by the tool."""
|
|
2
|
+
import ast
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
NAMES = {k: v for k, v in dict(int=int, str=str, bytes=bytes, len=len, hex=hex,
|
|
7
|
+
bin=bin, abs=abs, min=min, max=max, round=round, sum=sum, list=list, tuple=tuple,
|
|
8
|
+
chr=chr, ord=ord, range=range, enumerate=enumerate, zip=zip, sorted=sorted,
|
|
9
|
+
float=float).items()}
|
|
10
|
+
ATTRS = {'to_bytes','from_bytes','fromhex','hex','decode','encode','replace','split','join','strip'}
|
|
11
|
+
ALLOWED = (ast.Expression,ast.Constant,ast.List,ast.Tuple,ast.Dict,ast.Set,ast.Load,
|
|
12
|
+
ast.Store,ast.BinOp,ast.UnaryOp,ast.BoolOp,ast.Compare,ast.IfExp,ast.Subscript,
|
|
13
|
+
ast.Slice,ast.Call,ast.Name,ast.Attribute,ast.keyword,ast.Add,ast.Sub,ast.Mult,
|
|
14
|
+
ast.Div,ast.FloorDiv,ast.Mod,ast.Pow,ast.BitAnd,ast.BitOr,ast.BitXor,ast.LShift,
|
|
15
|
+
ast.RShift,ast.UAdd,ast.USub,ast.Invert,ast.And,ast.Or,ast.Not,ast.Eq,ast.NotEq,
|
|
16
|
+
ast.Lt,ast.LtE,ast.Gt,ast.GtE,ast.In,ast.NotIn,ast.ListComp,ast.SetComp,
|
|
17
|
+
ast.DictComp,ast.GeneratorExp,ast.comprehension,ast.Lambda,ast.arguments,ast.arg)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def calculate(expression):
|
|
21
|
+
if len(expression) > 10000:
|
|
22
|
+
raise ValueError("Expression too long")
|
|
23
|
+
tree = ast.parse(expression, mode='eval')
|
|
24
|
+
nodes = list(ast.walk(tree))
|
|
25
|
+
if len(nodes) > 2000:
|
|
26
|
+
raise ValueError("Expression too complex")
|
|
27
|
+
bound = {n.arg for n in nodes if isinstance(n, ast.arg)} | {
|
|
28
|
+
n.id for n in nodes if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store)}
|
|
29
|
+
for node in nodes:
|
|
30
|
+
if not isinstance(node, ALLOWED):
|
|
31
|
+
raise ValueError("Unsupported expression")
|
|
32
|
+
if isinstance(node, ast.Name) and node.id not in NAMES and node.id not in bound:
|
|
33
|
+
raise ValueError("Unsupported name")
|
|
34
|
+
if isinstance(node, ast.Attribute) and node.attr not in ATTRS:
|
|
35
|
+
raise ValueError("Unsupported attribute")
|
|
36
|
+
return repr(eval(compile(tree, '<calculator>', 'eval'), {'__builtins__': {}, **NAMES}, {}))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == '__main__':
|
|
40
|
+
try:
|
|
41
|
+
import resource
|
|
42
|
+
resource.setrlimit(resource.RLIMIT_CPU, (2, 2))
|
|
43
|
+
# Darwin advertises RLIMIT_AS but rejects setting it (EINVAL). Keep the
|
|
44
|
+
# CPU limit and caller's four-second timeout there; do not fail every sum.
|
|
45
|
+
if sys.platform != 'darwin':
|
|
46
|
+
resource.setrlimit(resource.RLIMIT_AS, (512*1024*1024, 512*1024*1024))
|
|
47
|
+
print(calculate(json.load(sys.stdin)['expression']))
|
|
48
|
+
except Exception as exc:
|
|
49
|
+
print(str(exc), file=sys.stderr)
|
|
50
|
+
sys.exit(1)
|
eaos/cli.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""CLI and stdio MCP, using only the standard library for transport."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from .backend import Backend
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def schema(properties=None, required=None):
|
|
15
|
+
return {'type':'object','properties':properties or {},'required':required or [],'additionalProperties':False}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
S = {'type':'string'}
|
|
19
|
+
B = {'type':'boolean','default':False}
|
|
20
|
+
TOOLS = [
|
|
21
|
+
('open_program','Selected target, memory map and analysis cache. full=true includes all metadata.',schema({'full':B})),
|
|
22
|
+
('search','Substring search of strings (with xrefs) or functions. Page with next_offset; full=true for signatures.',schema({'kind':{'type':'string','enum':['strings','functions']},'pattern':S,'offset':{'type':'integer','minimum':0},'limit':{'type':'integer','minimum':1,'maximum':200},'full':B})),
|
|
23
|
+
('inspect','Batch addresses. Default computed pseudocode. mode=calls, references, assembly or all; full=true expands. Omitted detail: read_evidence.',schema({'addresses':{'type':'array','items':S,'minItems':1,'maxItems':8},'mode':{'type':'string','enum':['decompile','assembly','references','calls','all']},'full':B},['addresses'])),
|
|
24
|
+
('read_memory','Fact: literal bytes at a virtual address. Default hex; format=utf8 or both for text display.',schema({'address':S,'length':{'type':'integer','minimum':1,'maximum':65536},'format':{'type':'string','enum':['hex','utf8','both']}},['address'])),
|
|
25
|
+
('calculate','Pure Python arithmetic/bytes expression. No imports, filesystem, network or program execution. Useful for numeric tables and packed constants.',schema({'expression':S},['expression'])),
|
|
26
|
+
('decode_swift_string','Decode 64-bit little-endian Swift small String storage. Supply address or two unsigned words. Reject unsupported pointer layouts instead of inventing text.',schema({'words':{'type':'array','items':{'anyOf':[S,{'type':'integer'}]},'minItems':2,'maxItems':2},'address':S})),
|
|
27
|
+
('read_evidence','Retrieve a content-checked evidence record by its returned id, with character paging. Never reads arbitrary paths.',schema({'id':S,'offset':{'type':'integer','minimum':0},'limit':{'type':'integer','minimum':1,'maximum':40000}},['id'])),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def protect_stdout():
|
|
32
|
+
sys.stdout.flush()
|
|
33
|
+
duplicate = os.dup(1)
|
|
34
|
+
os.dup2(2, 1)
|
|
35
|
+
sys.stdout = os.fdopen(duplicate, 'w', encoding='utf-8', buffering=1)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def serve(backend):
|
|
39
|
+
catalog = [{'name':name,'description':description,'inputSchema':spec} for name,description,spec in TOOLS]
|
|
40
|
+
allowed = {t['name'] for t in catalog}
|
|
41
|
+
for line in sys.stdin:
|
|
42
|
+
try:
|
|
43
|
+
request = json.loads(line)
|
|
44
|
+
except ValueError:
|
|
45
|
+
continue
|
|
46
|
+
if 'id' not in request:
|
|
47
|
+
continue
|
|
48
|
+
try:
|
|
49
|
+
method = request['method']
|
|
50
|
+
if method == 'initialize':
|
|
51
|
+
result = {'protocolVersion':request.get('params',{}).get('protocolVersion','2024-11-05'),
|
|
52
|
+
'capabilities':{'tools':{}},'serverInfo':{'name':'every-app-is-open-source','version':'0.1.0'}}
|
|
53
|
+
elif method == 'ping': result = {}
|
|
54
|
+
elif method == 'tools/list': result = {'tools':catalog}
|
|
55
|
+
elif method == 'tools/call':
|
|
56
|
+
name = request['params']['name']
|
|
57
|
+
if name not in allowed: raise ValueError('Unknown tool')
|
|
58
|
+
try:
|
|
59
|
+
output = getattr(backend,name)(**request['params'].get('arguments',{}))
|
|
60
|
+
result = {'content':[{'type':'text','text':json.dumps(output,ensure_ascii=False,separators=(',',':'))}]}
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
result = {'isError':True,'content':[{'type':'text','text':json.dumps({'error':str(exc),'classification':'fact','scope':'tool failure; no analysis claim'})}]}
|
|
63
|
+
else: raise ValueError('Unsupported method')
|
|
64
|
+
response = {'jsonrpc':'2.0','id':request['id'],'result':result}
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
response = {'jsonrpc':'2.0','id':request['id'],'error':{'code':-32603,'message':str(exc)}}
|
|
67
|
+
print(json.dumps(response,ensure_ascii=False),flush=True)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main():
|
|
71
|
+
parser = argparse.ArgumentParser(description='Every App Is Open Source: small native analysis tools')
|
|
72
|
+
sub = parser.add_subparsers(dest='command',required=True)
|
|
73
|
+
setup = sub.add_parser('setup',help='Download verified Java/Ghidra and build the Apple Silicon decompiler')
|
|
74
|
+
setup.add_argument('--rebuild',action='store_true',help='Rebuild the ARM64 decompiler')
|
|
75
|
+
setup.add_argument('--archive-cache',type=Path,
|
|
76
|
+
help='Use an existing directory of checksum-verified runtime archives')
|
|
77
|
+
server = sub.add_parser('mcp'); server.add_argument('binary')
|
|
78
|
+
call = sub.add_parser('call');call.add_argument('binary');call.add_argument('tool',choices=[t[0] for t in TOOLS]);call.add_argument('--args',default='{}')
|
|
79
|
+
args = parser.parse_args()
|
|
80
|
+
if args.command == 'setup':
|
|
81
|
+
from .runtime import install
|
|
82
|
+
try:
|
|
83
|
+
install(args.rebuild, args.archive_cache.expanduser().resolve() if args.archive_cache else None)
|
|
84
|
+
except (RuntimeError,OSError,subprocess.CalledProcessError) as exc:
|
|
85
|
+
parser.exit(1, f'EAOS setup failed: {exc}\n')
|
|
86
|
+
return
|
|
87
|
+
protect_stdout()
|
|
88
|
+
backend = Backend(args.binary)
|
|
89
|
+
try:
|
|
90
|
+
if args.command == 'mcp': serve(backend)
|
|
91
|
+
else: print(json.dumps(getattr(backend,args.tool)(**json.loads(args.args)),ensure_ascii=False,indent=2))
|
|
92
|
+
finally:
|
|
93
|
+
backend.close()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == '__main__':
|
|
97
|
+
main()
|
eaos/distributions.json
ADDED
eaos/evidence.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Durable, content-checked evidence stored alongside the input program."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Evidence:
|
|
11
|
+
def __init__(self, directory: Path, target_sha256: str):
|
|
12
|
+
self.directory = directory
|
|
13
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
self.target_sha256 = target_sha256
|
|
15
|
+
|
|
16
|
+
def save(self, operation: str, classification: str, data, limitations=None):
|
|
17
|
+
if classification not in {"fact", "computed", "guess"}:
|
|
18
|
+
raise ValueError("Unknown evidence classification")
|
|
19
|
+
payload = {"target_sha256": self.target_sha256,
|
|
20
|
+
"operation": operation, "classification": classification,
|
|
21
|
+
"limitations": limitations or [], "data": data}
|
|
22
|
+
canonical = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode()
|
|
23
|
+
identifier = hashlib.sha256(canonical).hexdigest()[:32]
|
|
24
|
+
path = self.directory / f"{identifier}.json"
|
|
25
|
+
record = {"id": identifier, **payload}
|
|
26
|
+
raw = json.dumps(record, ensure_ascii=False, indent=2, sort_keys=True).encode()
|
|
27
|
+
if path.exists() and path.read_bytes() != raw:
|
|
28
|
+
raise ValueError('Evidence identity collision or existing record changed')
|
|
29
|
+
path.write_bytes(raw)
|
|
30
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
31
|
+
(self.directory / f"{identifier}.sha256").write_text(digest + "\n")
|
|
32
|
+
return {"id": identifier, "path": str(path), "uri": path.as_uri(),
|
|
33
|
+
"sha256": digest, "target_sha256": self.target_sha256}
|
|
34
|
+
|
|
35
|
+
def read(self, identifier: str, offset: int = 0, limit: int = 12000):
|
|
36
|
+
if not re.fullmatch(r"[0-9a-f]{32}", identifier):
|
|
37
|
+
raise ValueError("Use an evidence id returned by this program's tools")
|
|
38
|
+
if offset < 0 or not 1 <= limit <= 40000:
|
|
39
|
+
raise ValueError("offset must be nonnegative; limit must be 1..40000")
|
|
40
|
+
path = self.directory / f"{identifier}.json"
|
|
41
|
+
raw = path.read_bytes()
|
|
42
|
+
if hashlib.sha256(raw).hexdigest() != (path.with_suffix('.sha256')).read_text().strip():
|
|
43
|
+
raise ValueError("Evidence hash mismatch")
|
|
44
|
+
text = raw.decode()
|
|
45
|
+
return {"classification": "fact", "id": identifier, "uri": path.as_uri(),
|
|
46
|
+
"offset": offset, "text": text[offset:offset+limit],
|
|
47
|
+
"next_offset": offset + limit if offset + limit < len(text) else None}
|
eaos/runtime.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Verified Java/Ghidra installation shared by wheels and checkout setup."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from importlib.resources import files
|
|
4
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
5
|
+
import fcntl
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tarfile
|
|
14
|
+
import tempfile
|
|
15
|
+
import urllib.request
|
|
16
|
+
|
|
17
|
+
GHIDRA = 'ghidra_12.1.4_PUBLIC'
|
|
18
|
+
JDK = 'jdk-21.0.12.1+1'
|
|
19
|
+
BUILD_ARGS = ['make','-B','-o','xml.cc','-o','grammar.cc','-o','pcodeparse.cc',
|
|
20
|
+
'-o','slghparse.cc','-o','slghscan.cc','-j4','ghidra_opt',
|
|
21
|
+
'CXX=clang++ -std=c++11','ARCH_TYPE=-arch arm64',
|
|
22
|
+
'ADDITIONAL_FLAGS=-mmacosx-version-min=11.0 -w']
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def digest(path):
|
|
26
|
+
with path.open('rb') as source:
|
|
27
|
+
return hashlib.file_digest(source, 'sha256').hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def verify_archive(path, pin):
|
|
31
|
+
if path.stat().st_size != pin['bytes'] or digest(path) != pin['sha256']:
|
|
32
|
+
raise RuntimeError(f'Archive integrity check failed: {path}. Keep it for inspection; supply a valid archive before retrying.')
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def download(url, destination, pin):
|
|
36
|
+
"""An interrupted or corrupt download never becomes the cached archive."""
|
|
37
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
temporary = None
|
|
39
|
+
try:
|
|
40
|
+
with tempfile.NamedTemporaryFile(dir=destination.parent, suffix='.part', delete=False) as out:
|
|
41
|
+
temporary = Path(out.name)
|
|
42
|
+
request = urllib.request.Request(url, headers={'User-Agent':'EAOS-setup'})
|
|
43
|
+
with urllib.request.urlopen(request, timeout=60) as response:
|
|
44
|
+
shutil.copyfileobj(response, out, length=1024*1024)
|
|
45
|
+
verify_archive(temporary, pin)
|
|
46
|
+
temporary.replace(destination)
|
|
47
|
+
finally:
|
|
48
|
+
if temporary is not None:
|
|
49
|
+
temporary.unlink(missing_ok=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def archive_url(name):
|
|
53
|
+
if name == 'jdk.tar.gz':
|
|
54
|
+
return 'https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_aarch64_mac_hotspot_21.0.12.1_1.tar.gz'
|
|
55
|
+
request = urllib.request.Request(
|
|
56
|
+
'https://api.github.com/repos/NationalSecurityAgency/ghidra/releases/tags/Ghidra_12.1.4_build',
|
|
57
|
+
headers={'User-Agent':'EAOS-setup'})
|
|
58
|
+
with urllib.request.urlopen(request, timeout=60) as response:
|
|
59
|
+
release = json.load(response)
|
|
60
|
+
matches = [a['browser_download_url'] for a in release['assets']
|
|
61
|
+
if a['name'].startswith(GHIDRA+'_') and a['name'].endswith('.zip')]
|
|
62
|
+
if len(matches) != 1:
|
|
63
|
+
raise RuntimeError('Pinned Ghidra release asset is missing or ambiguous')
|
|
64
|
+
return matches[0]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def unpack(archive, tools, directory):
|
|
68
|
+
"""Commit a complete extraction; leave an existing runtime untouched."""
|
|
69
|
+
destination = tools/directory
|
|
70
|
+
if destination.exists():
|
|
71
|
+
raise RuntimeError(f'Refusing to replace an existing runtime: {destination}')
|
|
72
|
+
with tempfile.TemporaryDirectory(dir=tools, prefix='.unpack-') as temporary:
|
|
73
|
+
staging = Path(temporary)
|
|
74
|
+
if archive.name.endswith('.zip'):
|
|
75
|
+
subprocess.run(['/usr/bin/ditto','-x','-k',str(archive),str(staging)],check=True)
|
|
76
|
+
else:
|
|
77
|
+
with tarfile.open(archive) as source:
|
|
78
|
+
source.extractall(staging,filter='data')
|
|
79
|
+
extracted = staging/directory
|
|
80
|
+
required = extracted/'Ghidra/application.properties' if directory == GHIDRA else extracted/'Contents/Home/bin/java'
|
|
81
|
+
if not required.is_file():
|
|
82
|
+
raise RuntimeError(f'Archive is missing its expected runtime: {directory}')
|
|
83
|
+
extracted.rename(destination)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def runtime_directory():
|
|
87
|
+
"""Installed packages never derive writable storage from site-packages."""
|
|
88
|
+
override = os.environ.get('EAOS_RUNTIME_DIR')
|
|
89
|
+
if override:
|
|
90
|
+
return Path(override).expanduser().resolve()
|
|
91
|
+
source = Path(__file__).resolve()
|
|
92
|
+
checkout = source.parents[2]
|
|
93
|
+
if source.parent.parent.name == 'src' and (checkout/'install.sh').is_file():
|
|
94
|
+
return checkout/'.tools'
|
|
95
|
+
return Path.home()/'Library/Application Support/Every App Is Open Source/runtimes'
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def prerequisites():
|
|
99
|
+
if platform.system() != 'Darwin' or platform.machine() != 'arm64':
|
|
100
|
+
raise RuntimeError('Only Apple Silicon macOS is currently supported; no runtime downloads were started.')
|
|
101
|
+
subprocess.run(['/usr/bin/xcode-select','-p'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,check=True)
|
|
102
|
+
for tool in ['make','clang++']:
|
|
103
|
+
subprocess.run(['/usr/bin/xcrun','--find',tool],stdout=subprocess.DEVNULL,check=True)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def configure():
|
|
107
|
+
tools = runtime_directory()
|
|
108
|
+
ghidra = tools/GHIDRA
|
|
109
|
+
java = tools/JDK/'Contents/Home'
|
|
110
|
+
required = [java/'bin/java', ghidra/'Ghidra/application.properties',
|
|
111
|
+
ghidra/'Ghidra/Features/Decompiler/os/mac_arm_64/decompile']
|
|
112
|
+
if not all(path.is_file() for path in required):
|
|
113
|
+
raise RuntimeError('Java/Ghidra setup is incomplete. Run eaos setup first. Runtime directory: '+str(tools))
|
|
114
|
+
# Match the checkout launcher: prefer our verified managed runtimes over
|
|
115
|
+
# unrelated JAVA_HOME/GHIDRA_INSTALL_DIR values inherited from a client.
|
|
116
|
+
os.environ['JAVA_HOME'] = str(java)
|
|
117
|
+
os.environ['GHIDRA_INSTALL_DIR'] = str(ghidra)
|
|
118
|
+
return tools, ghidra, java
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def install(rebuild=False, archive_cache=None):
|
|
122
|
+
prerequisites()
|
|
123
|
+
tools = runtime_directory()
|
|
124
|
+
tools.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
with (tools/'setup.lock').open('a+') as lock:
|
|
126
|
+
try:
|
|
127
|
+
fcntl.flock(lock,fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
128
|
+
except BlockingIOError:
|
|
129
|
+
raise RuntimeError('Another installation is running in '+str(tools)) from None
|
|
130
|
+
return install_locked(tools, rebuild, archive_cache)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def install_locked(tools, rebuild=False, archive_cache=None):
|
|
134
|
+
pins = json.loads(files('eaos').joinpath('distributions.json').read_text())
|
|
135
|
+
archives = {};downloaded = []
|
|
136
|
+
for name,directory in [('ghidra.zip',GHIDRA),('jdk.tar.gz',JDK)]:
|
|
137
|
+
destination = tools/directory
|
|
138
|
+
required = destination/'Ghidra/application.properties' if directory == GHIDRA else destination/'Contents/Home/bin/java'
|
|
139
|
+
if destination.exists():
|
|
140
|
+
if not required.is_file():
|
|
141
|
+
raise RuntimeError(f'Incomplete runtime at {destination}; move it aside before retrying.')
|
|
142
|
+
continue
|
|
143
|
+
archive = (archive_cache or tools/'distributions')/name
|
|
144
|
+
if not archive.is_file():
|
|
145
|
+
if archive_cache is not None:
|
|
146
|
+
raise RuntimeError(f'Missing archive in supplied cache: {archive}')
|
|
147
|
+
print(f'Downloading pinned {name} ({pins[name]["bytes"]:,} bytes)…',flush=True)
|
|
148
|
+
download(archive_url(name),archive,pins[name]);downloaded.append(name)
|
|
149
|
+
verify_archive(archive,pins[name]);archives[name]=str(archive)
|
|
150
|
+
print(f'Extracting verified {name}…',flush=True)
|
|
151
|
+
unpack(archive,tools,directory)
|
|
152
|
+
ghidra = tools/GHIDRA;java = tools/JDK/'Contents/Home'
|
|
153
|
+
properties = (ghidra/'Ghidra/application.properties').read_text()
|
|
154
|
+
if 'application.version=12.1.4' not in properties.splitlines():
|
|
155
|
+
raise RuntimeError('Unexpected Ghidra version')
|
|
156
|
+
output = ghidra/'Ghidra/Features/Decompiler/os/mac_arm_64/decompile'
|
|
157
|
+
rebuilt = rebuild or not output.is_file()
|
|
158
|
+
if rebuilt:
|
|
159
|
+
print('Building the Apple Silicon decompiler; log: '+str(tools/'decompiler-build.log'),flush=True)
|
|
160
|
+
cpp = ghidra/'Ghidra/Features/Decompiler/src/decompile/cpp'
|
|
161
|
+
# Keep release-generated parsers; no Bison, CMake or other build installs.
|
|
162
|
+
with (tools/'decompiler-build.log').open('w') as log:
|
|
163
|
+
subprocess.run(BUILD_ARGS,cwd=cpp,stdout=log,stderr=subprocess.STDOUT,check=True)
|
|
164
|
+
output.parent.mkdir(parents=True,exist_ok=True)
|
|
165
|
+
temporary = output.with_name('.eaos-decompile')
|
|
166
|
+
try:
|
|
167
|
+
shutil.copy2(cpp/'ghidra_opt',temporary);temporary.replace(output)
|
|
168
|
+
finally:temporary.unlink(missing_ok=True)
|
|
169
|
+
check = subprocess.check_output(['file',str(output)],text=True).strip()
|
|
170
|
+
if 'arm64' not in check:
|
|
171
|
+
raise RuntimeError('Decompiler is not an ARM64 executable: '+check)
|
|
172
|
+
java_check = subprocess.run([str(java/'bin/java'),'-version'],capture_output=True,text=True,check=True)
|
|
173
|
+
if 'version "21.' not in java_check.stderr:
|
|
174
|
+
raise RuntimeError('Unexpected Java version: '+java_check.stderr)
|
|
175
|
+
try:
|
|
176
|
+
pyghidra_version = version('pyghidra')
|
|
177
|
+
except PackageNotFoundError:
|
|
178
|
+
pyghidra_version = None # The checkout installs bindings after native setup.
|
|
179
|
+
report = {'ghidra':str(ghidra),'java_home':str(java),'java_version':java_check.stderr.splitlines()[0],
|
|
180
|
+
'python':platform.python_version(),'pyghidra':pyghidra_version,
|
|
181
|
+
'python_executable':sys.executable,'package_location':str(Path(__file__).resolve().parent),
|
|
182
|
+
'decompiler':check,'decompiler_sha256':digest(output),'decompiler_built':rebuilt,
|
|
183
|
+
'verified_archives':archives,'downloaded':downloaded}
|
|
184
|
+
temporary = tools/'setup.json.part'
|
|
185
|
+
temporary.write_text(json.dumps(report,indent=2)+'\n')
|
|
186
|
+
temporary.replace(tools/'setup.json')
|
|
187
|
+
print('Ready. Java and Ghidra: '+str(tools),flush=True)
|
|
188
|
+
print('Receipt: '+str(tools/'setup.json'),flush=True)
|
|
189
|
+
return report
|
eaos/swift.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Decode the documented Swift small String bit layout; do not guess pointers."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def decode_small_string(words):
|
|
5
|
+
if len(words) != 2:
|
|
6
|
+
raise ValueError("Supply two 64-bit words in memory order")
|
|
7
|
+
values = [int(w, 0) if isinstance(w, str) else int(w) for w in words]
|
|
8
|
+
if any(w < 0 or w >= 1 << 64 for w in values):
|
|
9
|
+
raise ValueError("Words must be unsigned 64-bit values")
|
|
10
|
+
raw = b''.join(w.to_bytes(8, 'little') for w in values)
|
|
11
|
+
tag = raw[15]
|
|
12
|
+
if tag & 0xA0 != 0xA0:
|
|
13
|
+
raise ValueError("Not a supported small Swift String discriminator; pointer-backed strings require memory inspection")
|
|
14
|
+
size = tag & 0x0F
|
|
15
|
+
payload = raw[:size]
|
|
16
|
+
text = payload.decode('utf-8', errors='strict')
|
|
17
|
+
if tag & 0x40 and not payload.isascii():
|
|
18
|
+
raise ValueError("ASCII discriminator conflicts with payload")
|
|
19
|
+
return {"text": text, "utf8_bytes": size, "discriminator": hex(tag),
|
|
20
|
+
"raw_hex": raw.hex(), "layout": "Swift small String, 64-bit little endian"}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: every-app-is-open-source
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Small, evidence-linked reverse-engineering tools for agents
|
|
5
|
+
Author-email: thomasisalwayslate <148886635+thomasisalwayslate@users.noreply.github.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/thomasisalwayslate/every-app-is-open-source
|
|
8
|
+
Requires-Python: <3.14,>=3.13
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
License-File: NOTICE
|
|
12
|
+
Requires-Dist: pyghidra==3.1.0
|
|
13
|
+
Requires-Dist: jpype1==1.5.2
|
|
14
|
+
Requires-Dist: packaging==26.3
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# Every App Is Open Source
|
|
18
|
+
|
|
19
|
+
**Same answers as REA. 36% cheaper. 27% faster.**
|
|
20
|
+
|
|
21
|
+
I built this to give an AI agent the tools to take a compiled program apart and work out what it does, without the source code. Ghidra does the actual decompiling. My part is everything around it: a small set of tools, short replies by default, honest labels on every answer, and evidence you can trace back to the binary.
|
|
22
|
+
|
|
23
|
+
Then I tested it against [REA](https://github.com/morluto/rea), the 300,000-star tool everyone's using, and against the community [GhidraMCP](https://github.com/LaurieWired/GhidraMCP). On a secretly modified Doom, all three got every answer right. Mine was the cheapest and the fastest.
|
|
24
|
+
|
|
25
|
+
> The name is a provocation, not a license. This shows you how an app works. It doesn't give you the right to copy it.
|
|
26
|
+
|
|
27
|
+
## Results
|
|
28
|
+
|
|
29
|
+
Same model for everyone (gpt-6.1-sol, high effort), fresh sessions, six questions, three runs per tool:
|
|
30
|
+
|
|
31
|
+
| Tool | Correct | Median time per run | Tokens, 3 runs | API-equivalent cost, 3 runs |
|
|
32
|
+
|---|---:|---:|---:|---:|
|
|
33
|
+
| **Every App Is Open Source** | **18/18** | **121.8s** | **1,728,000** | **$0.70** |
|
|
34
|
+
| Community GhidraMCP | 18/18 | 146.2s | 2,337,886 | $0.84 |
|
|
35
|
+
| REA 6.7.0 + its bundled skill | 18/18 | 166.7s | 3,603,877 | $1.09 |
|
|
36
|
+
|
|
37
|
+
**How I kept it honest:**
|
|
38
|
+
- **Doom's answers are all over the internet, so I changed them.** I built Chocolate Doom with eight gameplay rules secretly altered and stripped its function names. An agent answering from memory gets the original values, and those are now wrong.
|
|
39
|
+
- **I froze the questions and the answer key before the first run.** The answer key comes from running the modified game code, not from me reading it.
|
|
40
|
+
- **Everyone got the same head start.** All three began with the program already analyzed, outside the timer.
|
|
41
|
+
- **REA ran the way people actually install it,** with its bundled skill and its full tool catalog.
|
|
42
|
+
|
|
43
|
+
**What this doesn't prove:** all three scored 100%, so mine isn't smarter, just cheaper and faster. It's one game, three runs per tool, compiled programs only.
|
|
44
|
+
|
|
45
|
+
My first version actually lost to GhidraMCP on cost. I read the logs, cut the repeated metadata and the detail nobody had asked for yet, then retested on new questions and a new build. Everything is kept, including the round I lost.
|
|
46
|
+
|
|
47
|
+
[Full results](reports/round2/results.md) · [Every run](reports/round2/results.json) · [Answer-by-answer check](reports/round2/answer-review.json) · [Frozen protocol](benchmark/protocol-round2.json) · [The round I lost](reports/results.md)
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
You need an Apple Silicon Mac, [uv](https://docs.astral.sh/uv/) and Apple's Command Line Tools (`xcode-select --install`).
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
uvx --python 3.13 --from every-app-is-open-source eaos setup
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
That downloads checksum-pinned Ghidra 12.1.4 and Java 21, and builds Ghidra's Apple Silicon decompiler, because the official download doesn't include one. Everything lands in `~/Library/Application Support/Every App Is Open Source/`. Nothing global, no shell profile edits, no touching your agent's settings. It takes under a minute, and running it again reuses what's there.
|
|
58
|
+
|
|
59
|
+
## Use it
|
|
60
|
+
|
|
61
|
+
Point your agent at one program:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
uvx --python 3.13 --from every-app-is-open-source eaos mcp /absolute/path/to/program
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
For an MCP client config, the command is `uvx` and the arguments are:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
["--python", "3.13", "--from", "every-app-is-open-source", "eaos", "mcp", "/absolute/path/to/program"]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Or try a single tool from the terminal:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
uvx --python 3.13 --from every-app-is-open-source eaos call /absolute/path/to/program open_program
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The server only sees the one program you give it. It can't read other files on your machine.
|
|
80
|
+
|
|
81
|
+
## The seven tools
|
|
82
|
+
|
|
83
|
+
I counted every tool call the agents made across my earlier 24 test runs and built only what they actually used.
|
|
84
|
+
|
|
85
|
+
| Tool | What it does |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `open_program` | What the program is: architecture, memory layout, analysis status |
|
|
88
|
+
| `search` | Find text inside the program and where it's used, or find functions |
|
|
89
|
+
| `inspect` | Decompiled code, raw instructions, callers and callees |
|
|
90
|
+
| `read_memory` | Read the actual bytes at an address |
|
|
91
|
+
| `calculate` | Safe arithmetic and byte decoding, so the agent doesn't do math in its head |
|
|
92
|
+
| `decode_swift_string` | Recover text hidden inside Swift code on Apple platforms |
|
|
93
|
+
| `read_evidence` | Pull up the full saved evidence behind any reply, hash-checked |
|
|
94
|
+
|
|
95
|
+
## Never "exact" for a guess
|
|
96
|
+
|
|
97
|
+
That was REA's worst habit: a name-based guess labelled "observed, exact." Here, every answer says what it is:
|
|
98
|
+
|
|
99
|
+
- **fact**: read straight from the binary
|
|
100
|
+
- **computed**: worked out deterministically, like decompiled code or a decoded string
|
|
101
|
+
- **guess**: an interpretation with nothing solid behind it
|
|
102
|
+
|
|
103
|
+
Decompiled code is computed, not the original source. When a tool fails, it says it failed.
|
|
104
|
+
|
|
105
|
+
## Analyze once, reuse forever
|
|
106
|
+
|
|
107
|
+
Ghidra's analysis is slow, so each program gets its own `eaos-analysis/<sha256>/` folder right next to it, holding the analyzed project and every piece of evidence. Opening the same program again reuses all of it.
|
|
108
|
+
|
|
109
|
+
One Ghidra quirk: it won't create projects inside a folder whose name starts with a dot, like `.cache`. Keep the program you're analyzing out of those.
|
|
110
|
+
|
|
111
|
+
## From a checkout
|
|
112
|
+
|
|
113
|
+
If you'd rather run it from the repo:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
./install.sh
|
|
117
|
+
./eaos mcp /absolute/path/to/program
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Same requirements. Java, Ghidra and Python all install inside the checkout.
|
|
121
|
+
|
|
122
|
+
## What's next
|
|
123
|
+
|
|
124
|
+
I only claim "better" where I've measured it:
|
|
125
|
+
|
|
126
|
+
| Area | Status |
|
|
127
|
+
|---|---|
|
|
128
|
+
| Compiled programs | ✅ Beat REA and GhidraMCP on cost and speed, on modified Doom |
|
|
129
|
+
| JavaScript / Electron apps | Next: VS Code, with its own hidden answer key |
|
|
130
|
+
| Android apps | Planned |
|
|
131
|
+
| .NET apps | Planned |
|
|
132
|
+
| Websites, watching apps run | Planned |
|
|
133
|
+
|
|
134
|
+
Each one gets the same treatment: a secretly modified target, a frozen answer key and a fair fight against REA, before I claim anything.
|
|
135
|
+
|
|
136
|
+
## Rerun my test
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
uv run python -m unittest discover -s tests -v
|
|
140
|
+
python3 benchmark/build_doom.py
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`build_doom.py` makes a fresh build with new random changes, so you get your own answer key. The binary and answer key from my run stay private, since publishing them would spoil the test. In the saved reports, file paths from my machine are shortened to `<repo>`.
|
|
144
|
+
|
|
145
|
+
## Credits
|
|
146
|
+
|
|
147
|
+
Ghidra and PyGhidra do the heavy lifting; they're the work of their own contributors. The comparison baseline is LaurieWired's GhidraMCP. The test target is Chocolate Doom, based on id Software's Doom and released under GPLv2.
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
[Apache 2.0](LICENSE). If you build on this, keep the [NOTICE](NOTICE) file. Before you point it at anything, read the [disclaimer](DISCLAIMER.md).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
eaos/__init__.py,sha256=KGP0y-awfQFFRKsNDjRDRGBTKLr1nC4hSmjzolQ91QM,89
|
|
2
|
+
eaos/backend.py,sha256=q--HSnl_tQN1nmn3izeuksrKzvi4OwvdJkti0eWL-aE,13477
|
|
3
|
+
eaos/calculator.py,sha256=jvktzy_TOo0edUHn04Z7dVNK5M0GxWGc-2MbJ6xivso,2529
|
|
4
|
+
eaos/cli.py,sha256=I3AnfxMdTUKR8pbzpCv7C80r4Txk5kRXAm6rDYpCWtY,5591
|
|
5
|
+
eaos/distributions.json,sha256=D17G5md2pnh3mPHKhV6ZH0YBZ-IfBEgHLvqGIzVI7tk,258
|
|
6
|
+
eaos/evidence.py,sha256=do29Hv-g0-YAR0oC2HhyxwPt4hWKM7UyVSLX4ZTEoSE,2448
|
|
7
|
+
eaos/runtime.py,sha256=QKWcUzC-7YQmoogZfhK1eDqUfgbJbvN7U58BJjiPM90,9167
|
|
8
|
+
eaos/swift.py,sha256=9xfXN5NX7M2EeuYy0QnFOa9ffmm_6EXg7ZhdplahCzY,1006
|
|
9
|
+
every_app_is_open_source-0.1.0.dist-info/licenses/LICENSE,sha256=yVuuHRzgI17MzTVgt3LsHvuX80innw--CmNPDCzO_iw,11358
|
|
10
|
+
every_app_is_open_source-0.1.0.dist-info/licenses/NOTICE,sha256=JDnaDWl6m2Cha5CvOyqRBuy0fXz3flC2iQ86Hk_HQ0w,324
|
|
11
|
+
every_app_is_open_source-0.1.0.dist-info/METADATA,sha256=QkGqKaGvuNI1RXRqfcMawFHnvSnUCFcDtid0xbbvE6c,7280
|
|
12
|
+
every_app_is_open_source-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
every_app_is_open_source-0.1.0.dist-info/entry_points.txt,sha256=munXUOz9BrB9TnziGx42RQB1XH1MVd2SDdzaiduW_uM,39
|
|
14
|
+
every_app_is_open_source-0.1.0.dist-info/top_level.txt,sha256=TINcf2knZ4iq3rc398i6PR8SetMMRVJDKQaoXPFd6Ck,5
|
|
15
|
+
every_app_is_open_source-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,202 @@
|
|
|
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. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
|
202
|
+
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Every App Is Open Source
|
|
2
|
+
Copyright 2026 thomasisalwayslate
|
|
3
|
+
https://github.com/thomasisalwayslate/every-app-is-open-source
|
|
4
|
+
|
|
5
|
+
This product includes software developed by thomasisalwayslate.
|
|
6
|
+
If you redistribute this software or build on it, keep this NOTICE file,
|
|
7
|
+
as required by section 4(d) of the Apache License, Version 2.0.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
eaos
|