Restructure to the skills/<name>/ layout the skills CLI resolves

The skill lived at the repository root, which the CLI reads as a single-skill repository and which makes a second skill impossible to add. SKILL.md, references/ and scripts/ move under skills/apk-reverse/ with git mv, so history follows and no in-skill reference needs changing (they are relative to SKILL.md).

check_repo.py now discovers every skills/*/SKILL.md, validates frontmatter by parsing it instead of pattern-matching it, and requires the README to name skills through explicit skills/<name>/... paths.

check_refs.py is new: it audits file.md section cross-references against the real headings in the target file. It found six dangling ones, all fixed here.

SKILL.md: the description is now quoted. Unquoted, it contained a colon-space, which is invalid YAML, so the CLI skipped the skill and reported No skills found. Adds a Coverage section stating what is out of scope and what to do when nothing matches. toolchain.md: IDA is not GUI-only (idalib-mcp is a headless MCP server), plus a section on MCP servers as external dependencies and the deobfuscate-before-LLM boundary. README.md: install via npx skills add, the new layout, coverage and maintenance tools.

Verified: check_repo.py and check_refs.py both clean, build_scripts.py audit 0 findings, npx skills add . --list reports apk-reverse, and an install into a scratch directory lands SKILL.md plus 28 references and 38 scripts.
This commit is contained in:
inliver233
2026-09-20 03:37:46 +08:00
parent a552157f1b
commit 7176b5e1c9
70 changed files with 676 additions and 91 deletions
+48 -14
View File
@@ -34,8 +34,9 @@ clean log is not one of them. Anything short of all six is a checkpoint, and sho
checkpoint with what remains. checkpoint with what remains.
If you are an agent reading this: the cheapest possible first command is If you are an agent reading this: the cheapest possible first command is
`python scripts/doctor.py`. It tells you which of these tools exist here, which scripts can actually `python skills/apk-reverse/scripts/doctor.py`. It tells you which of these tools exist here, which
run, and whether something in the environment is already poisoning your measurements. scripts can actually run, and whether something in the environment is already poisoning your
measurements.
## What it is good at ## What it is good at
@@ -76,6 +77,10 @@ run, and whether something in the environment is already poisoning your measurem
## Structure ## Structure
`SKILL.md`, `references/` and `scripts/` are all inside the skill directory, `skills/apk-reverse/`.
Everything at the repository root is maintenance tooling shared across skills, not part of an
installed skill.
``` ```
SKILL.md a procedure with gates, not background reading: SKILL.md a procedure with gates, not background reading:
how-to-use -> four override rules (R1-R4) how-to-use -> four override rules (R1-R4)
@@ -229,20 +234,29 @@ scripts/ parameterized, path-agnostic
## Install ## Install
Drop this directory where your agent finds skills, e.g.: This repository is a **skills repository**: the skill lives at `skills/apk-reverse/`, which is the
layout the `skills` CLI resolves, and it is installed by name rather than by copying a directory:
``` ```
<skills-dir>/apk-reverse/SKILL.md npx skills add newliver666/apk-reverse # install every skill in the repo
npx skills add newliver666/apk-reverse --list # list what is here, install nothing
npx skills add newliver666/apk-reverse --skill apk-reverse -y
npx skills use newliver666/apk-reverse@apk-reverse # use it once, without installing
``` ```
The agent loads `SKILL.md` when a task matches its description, and pulls in The CLI symlinks the skill into your agent's skills directory by default (`--copy` makes independent
`references/*` only as needed. No global state, no machine-specific paths. copies instead), and `-g` installs for every project rather than the current one. With one skill in
the repository, `--skill apk-reverse` is redundant today; it is written out here because it is what
selects a single skill once a second one exists.
Once installed, the agent loads `SKILL.md` when a task matches its description, and pulls in
`references/*` only as needed. No global state, no machine-specific paths, and no build step.
## Requirements ## Requirements
Nothing is mandatory; each script checks what it needs. `scripts/doctor.py` reports which of these Nothing is mandatory; each script checks what it needs. `skills/apk-reverse/scripts/doctor.py` reports
are present here, which scripts can therefore run, and — usefully — which tools exist somewhere other which of these are present here, which scripts can therefore run, and — usefully — which tools exist
than PATH. somewhere other than PATH.
If your toolchain lives outside PATH (a project-local `tools/` directory, a versioned SDK folder, a If your toolchain lives outside PATH (a project-local `tools/` directory, a versioned SDK folder, a
runnable `.jar` instead of a command), set `APKREV_TOOLS` to one or more directories and `doctor.py` runnable `.jar` instead of a command), set `APKREV_TOOLS` to one or more directories and `doctor.py`
@@ -259,7 +273,7 @@ Linux; where a snippet is POSIX-only it is labelled. Nothing here assumes a Unix
| Tool | Used for | | Tool | Used for |
|---|---| |---|---|
| Python 3.9+ | all scripts | | Python 3.9+ | all scripts |
| `ddc` (optional but recommended) | single-binary dex→Java decompiler with query subcommands (`info`, `findrefs`, `strings --with-locations`, per-class decompile). No JVM. Turns string cross-referencing from a crawl into a lookup, and reports package identity reliably — see `references/toolchain.md` | | `ddc` (optional but recommended) | single-binary dex→Java decompiler with query subcommands (`info`, `findrefs`, `strings --with-locations`, per-class decompile). No JVM. Turns string cross-referencing from a crawl into a lookup, and reports package identity reliably — see `skills/apk-reverse/references/toolchain.md` |
| `baksmali` / `smali` + `dexlib2` jars | disassembly, assembly, surgical patching | | `baksmali` / `smali` + `dexlib2` jars | disassembly, assembly, surgical patching |
| JDK (`javac`, `java`) | building/running the dexlib2 patcher; also provides `keytool`/`jarsigner` | | JDK (`javac`, `java`) | building/running the dexlib2 patcher; also provides `keytool`/`jarsigner` |
| Android SDK build-tools (`aapt`, `zipalign`, `apksigner`) | manifest info, alignment, signing. **`apksigner` is the signer to use** — `jarsigner` rewrites the archive and breaks the alignment Android R+ requires | | Android SDK build-tools (`aapt`, `zipalign`, `apksigner`) | manifest info, alignment, signing. **`apksigner` is the signer to use** — `jarsigner` rewrites the archive and breaks the alignment Android R+ requires |
@@ -269,13 +283,14 @@ Linux; where a snippet is POSIX-only it is labelled. Nothing here assumes a Unix
| a rooted device or emulator | anything beyond static analysis | | a rooted device or emulator | anything beyond static analysis |
None of these need to be on `PATH`: every script accepts an explicit path for the None of these need to be on `PATH`: every script accepts an explicit path for the
tools it shells out to, and `references/toolchain.md` covers finding an install that tools it shells out to, and `skills/apk-reverse/references/toolchain.md` covers finding
`PATH` does not know about (the common case for `apksigner` and `keytool`). an install that `PATH` does not know about (the common case for `apksigner` and
`keytool`).
## Read this first ## Read this first
`references/pitfalls.md`. It is the most valuable file here — every entry is a failure `skills/apk-reverse/references/pitfalls.md`. It is the most valuable file here — every entry is a
that produced a broken artifact while looking completely healthy. failure that produced a broken artifact while looking completely healthy.
The four that hurt most: The four that hurt most:
@@ -294,3 +309,22 @@ The four that hurt most:
Built for working on your own applications, on samples you are authorized to analyze, Built for working on your own applications, on samples you are authorized to analyze,
and in CTF/competition sandboxes. It contains no vendored third-party binaries and no and in CTF/competition sandboxes. It contains no vendored third-party binaries and no
target-specific data. target-specific data.
What it covers, and what it deliberately does not, is stated at the top of `SKILL.md`
under **Coverage**. The short version: Android only (no iOS), and deep on the layers
that have been worked through for real — dex patching, repacking, packers and custom
loaders, native tamper response, and Flutter/Dart AOT. Unity/IL2CPP logic recovery,
React Native/Hermes bytecode internals, and defeating a server-side authority are **not**
covered, and the skill is written to say so and stop rather than apply the nearest
documented procedure to a target it was not written for.
## Repository maintenance
Three tools live at the root and are not part of the installed skill:
```
check_repo.py every skill discovered, frontmatter valid, scripts runnable,
documented paths resolve, README paths explicit and existing
check_refs.py every `file.md` §section cross-reference reaches a real heading
build_scripts.py audit for machine-specific leftovers (absolute paths, credentials)
```
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""Audit cross-references of the form `file.md` §section, and `file.md` P12.
A reference like `references/toolchain.md` §"not on PATH" is a promise that the
target file has a section by that name. When a section is renamed or removed the
promise silently breaks: the reader arrives at a file that does not contain the
thing they were sent for, and the most likely outcome is that they conclude the
material is missing rather than that the link is stale. Prose links do not fail
loudly the way a missing file does, which is why this script exists separately
from `check_repo.py` (that one only proves the *file* resolves).
A section reference is reported as:
* OK -- normalised, it matches a heading (or bold numbered rule) in the target
* WARN -- its words appear in the target body, but no heading carries them;
usually a stale or paraphrased link, worth a human look
* FAIL -- nothing in the target file corresponds; the link is dangling
Layout is discovered the same way as in `check_repo.py`, so a second skill under
skills/ is audited with no change here.
"""
import os
import re
import sys
ROOT = os.path.dirname(os.path.abspath(__file__))
SKILLS_DIR = os.path.join(ROOT, 'skills')
# A section reference. The optional leading file may be separated from the
# section mark by the closing backtick and a line break, since references are
# often wrapped mid-sentence.
REF_RE = re.compile(
r'(?:(?P<file>[A-Za-z0-9_\-]+\.md)`?[\s`]{0,40})?'
r'§\s*(?P<sec>[^\n|`();,.\u2014\u3002]{2,90})')
# A failure-catalogue reference, e.g. `pitfalls.md` P31, or a bare (P18).
PITFALL_RE = re.compile(
r'(?:(?P<file>[A-Za-z0-9_\-]+\.md)`?[\s`]{0,40})?'
r'\(?\bP(?P<num>\d{1,2})\b')
HEADING_RE = re.compile(r'^#{1,6}\s+(?P<title>.+?)\s*$')
BOLD_RE = re.compile(r'^\*\*(?P<title>.+?)\*\*\s*$')
def norm(text):
"""Fold a heading or a reference into comparable tokens.
Hyphens and slashes become spaces so that `finding-the-call-site` matches
"Finding the call site", and quotes/emphasis are dropped so that
`§"not on PATH"` matches a heading containing the same words quoted.
"""
text = text.lower()
text = text.replace('\u2014', ' ').replace('\u2013', ' ')
text = re.sub(r'[`*_"\'()\[\]]', '', text)
text = re.sub(r'[^a-z0-9]+', ' ', text)
return text.strip()
def discover_skills():
"""Return [(name, skill_dir)] for each skill root, shallow shadowing deep."""
found = []
if not os.path.isdir(SKILLS_DIR):
return found
for dirpath, dirnames, filenames in os.walk(SKILLS_DIR):
dirnames[:] = sorted(d for d in dirnames if not d.startswith('.'))
rel = os.path.relpath(dirpath, SKILLS_DIR)
depth = 0 if rel == '.' else rel.count(os.sep) + 1
if depth > 3:
dirnames[:] = []
continue
if 'SKILL.md' in filenames:
found.append((rel.replace(os.sep, '/'), dirpath))
dirnames[:] = []
return sorted(found)
def discover_docs():
"""Return [(path, skill_name_or_None, text)] for every markdown document."""
docs = []
readme = os.path.join(ROOT, 'README.md')
if os.path.exists(readme):
docs.append((readme, None, open(readme, encoding='utf-8').read()))
for skill, skill_dir in discover_skills():
for dirpath, dirnames, filenames in os.walk(skill_dir):
dirnames[:] = sorted(d for d in dirnames if not d.startswith('.'))
for fname in sorted(filenames):
if not fname.endswith('.md'):
continue
path = os.path.join(dirpath, fname)
docs.append((path, skill, open(path, encoding='utf-8').read()))
return docs
def anchors(text):
"""Headings, and bold stand-alone lines (`**2a. ...**`), as match targets."""
out = []
for line in text.splitlines():
line = line.strip()
m = HEADING_RE.match(line) or BOLD_RE.match(line)
if m:
out.append(m.group('title'))
return out
def matches(sec, anchor_list):
want = norm(sec)
if not want:
return True
want_tokens = want.split()
for raw in anchor_list:
have_tokens = norm(raw).split()
if not have_tokens:
continue
# A numbered heading (`## 3. Reading AOT code`) carries the number as a
# token, but a reference may name the section with or without it, so
# both readings are candidates.
candidates = [have_tokens]
if have_tokens[0].isdigit() and len(have_tokens) > 1:
candidates.append(have_tokens[1:])
for have in candidates:
text = ' '.join(have)
if text == want or text.startswith(want + ' ') \
or want.startswith(text + ' '):
return True
if all(tok in have for tok in want_tokens):
return True
# A reference frequently runs on into the sentence that follows it
# (`§Gates are cleared here or not at all`), leaving prose attached
# to the heading name. Sharing the leading word is the strongest
# signal available in that shape.
if have[0] == want_tokens[0]:
return True
return False
def first_token_match(sec, anchor_list):
"""True when some heading starts with the same word as the reference.
A reference is often a heading plus the rest of the sentence
(`§Gates are cleared here or not at all`), so an exact match is too strict
to be the only accepted shape. This is deliberately weaker than matches()
and is reported as a warning, not as a pass.
"""
want = norm(sec).split()
if not want:
return False
for raw in anchor_list:
have = norm(raw).split()
if have and have[0] == want[0]:
return True
return False
def in_body(sec, text):
"""Weaker fallback: every token of the section name occurs in the body."""
want_tokens = norm(sec).split()
if not want_tokens:
return True
body = norm(text)
return all(tok in body for tok in want_tokens)
def main():
docs = discover_docs()
by_name = {}
for path, skill, text in docs:
by_name.setdefault(os.path.basename(path), []).append((path, skill, text))
ok = warn = 0
problems = []
warnings = []
for path, skill, text in docs:
where = os.path.relpath(path, ROOT).replace(os.sep, '/')
for m in REF_RE.finditer(text):
raw_sec = m.group('sec').strip().strip('.,;:!?\'")*').lower()
# `§7 gives the exact framing` -- keep only the number.
num = re.match(r'(\d{1,2}[a-z]?)\b', raw_sec)
sec = num.group(1) if num else raw_sec
if not sec:
continue
# `§8C` names sub-item C of section 8, so the bare section number is
# an acceptable fallback target.
variants = [sec]
sub = re.fullmatch(r'(\d{1,2})[a-z]', sec)
if sub:
variants.append(sub.group(1))
target_name = m.group('file')
if target_name is None:
target_text, target_list = text, [path]
target_label = where
else:
candidates = [c for c in by_name.get(target_name, [])
if skill is None or c[1] == skill] \
or by_name.get(target_name, [])
if not candidates:
problems.append('%s: §%s -> %s (no such file)'
% (where, sec, target_name))
continue
target_text = candidates[0][2]
target_label = os.path.relpath(candidates[0][0], ROOT).replace(os.sep, '/')
if any(matches(v, anchors(target_text)) for v in variants):
ok += 1
elif len(norm(sec).split()) >= 2 and first_token_match(
sec, anchors(target_text)):
warn += 1
warnings.append('%s: §%s -> %s (heading starts with this word '
'but the wording differs)'
% (where, sec, target_label))
elif in_body(sec, target_text):
warn += 1
warnings.append('%s: §%s -> %s (words present, no matching heading)'
% (where, sec, target_label))
else:
problems.append('%s: §%s -> %s (section not found)'
% (where, sec, target_label))
# Failure-catalogue references: `pitfalls.md` P31, or a bare (P18).
pit_anchors = set()
for name, entries in by_name.items():
for path, _skill, text in entries:
for raw in anchors(text):
m = re.match(r'P(\d{1,2})[.\s]', raw)
if m:
pit_anchors.add(m.group(1))
for path, _skill, text in docs:
where = os.path.relpath(path, ROOT).replace(os.sep, '/')
for m in PITFALL_RE.finditer(text):
if m.group('num') not in pit_anchors:
problems.append('%s: P%s (no such pitfall)' % (where, m.group('num')))
else:
ok += 1
print('== markdown documents ==')
for path, _skill, _text in docs:
print(' %s' % os.path.relpath(path, ROOT).replace(os.sep, '/'))
print('\n== section references: %d reachable ==' % ok)
for w in sorted(set(warnings)):
print(' WARN %s' % w)
for p in sorted(set(problems)):
print(' FAIL %s' % p)
print('\n== result: %d dangling, %d warning(s) =='
% (len(set(problems)), len(set(warnings))))
return 1 if problems else 0
if __name__ == '__main__':
sys.exit(main())
+256 -65
View File
@@ -1,80 +1,271 @@
"""Pre-commit checks: every script parses and answers --help; every referenced file exists.""" #!/usr/bin/env python3
"""Pre-commit checks for this repository.
Assumed layout -- the one the `skills` CLI resolves:
README.md repository-level docs (root)
skills/<skill-name>/SKILL.md the skill itself
skills/<skill-name>/references/*.md loaded on demand
skills/<skill-name>/scripts/* run, not read
Every skill under `skills/` is discovered automatically (up to three levels;
a SKILL.md at a shallower level shadows anything nested below it, which is the
CLI's own rule), so adding a second skill requires no change to this script.
Per skill:
1. frontmatter exists, `name` matches the directory name, `description` <= 1024
2. every scripts/*.py parses and answers --help without a traceback
3. every `references/X.md` / `scripts/X.py` reference resolves inside that skill
4. every reference file is reachable from that skill's SKILL.md or the README
5. every script is mentioned somewhere in the docs
On the root README:
6. every path it names is an explicit `skills/<name>/...` path that exists
"""
import ast import ast
import os import os
import re import re
import subprocess import subprocess
import sys import sys
try:
import yaml
except ImportError: # the skills CLI is the real authority; this is a local gate
yaml = None
ROOT = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(os.path.abspath(__file__))
os.chdir(ROOT) SKILLS_DIR = os.path.join(ROOT, 'skills')
README = os.path.join(ROOT, 'README.md')
REF_RE = re.compile(r'`?(references/[A-Za-z0-9_\-]+\.md)')
SCRIPT_RE = re.compile(r'`?(scripts/[A-Za-z0-9_\-]+\.(?:py|js))')
NAMED_PATH_RE = re.compile(
r'skills/(?P<skill>[a-z0-9\-]+)/(?P<sub>references|scripts)/'
r'(?P<file>[A-Za-z0-9_\-]+\.(?:md|py|js))')
BARE_PATH_RE = re.compile(r'(?<![\w/])(?P<sub>references|scripts)/'
r'(?P<file>[A-Za-z0-9_\-]+\.(?:md|py|js))')
fail = [] fail = []
print("== python syntax + --help ==")
for name in sorted(os.listdir('scripts')): def read(path):
if not name.endswith('.py'): with open(path, encoding='utf-8') as fh:
continue return fh.read()
path = os.path.join('scripts', name)
def discover_skills():
"""Return [(name, skill_dir)] for every skills/<...>/SKILL.md."""
found = []
if not os.path.isdir(SKILLS_DIR):
return found
for dirpath, dirnames, filenames in os.walk(SKILLS_DIR):
dirnames[:] = sorted(d for d in dirnames if not d.startswith('.'))
rel = os.path.relpath(dirpath, SKILLS_DIR)
depth = 0 if rel == '.' else rel.count(os.sep) + 1
if depth > 3:
dirnames[:] = []
continue
if 'SKILL.md' in filenames:
found.append((rel.replace(os.sep, '/'), dirpath))
dirnames[:] = [] # shallower SKILL.md shadows what is below
return sorted(found)
NAME_RE = re.compile(r'^[a-z0-9]+(?:-[a-z0-9]+)*$')
def check_frontmatter(name, skill_dir):
"""Validate the frontmatter the way the CLI will, not merely its presence.
This has to be a real parse. Frontmatter that satisfies a regex can still be
rejected by a YAML parser -- an unquoted `description` containing ": " does
it, because the colon starts a nested mapping -- and the `skills` CLI then
reports "No skills found" and installs nothing at all.
"""
path = os.path.join(skill_dir, 'SKILL.md')
text = read(path)
m = re.match(r'^---\r?\n(.*?)\r?\n---\r?\n', text, re.S)
if not m:
fail.append('%s: no YAML frontmatter block' % path)
return
raw = m.group(1)
if yaml is None:
print(' WARN PyYAML is not installed, so the frontmatter was not parsed')
line = re.search(r'^description:(.*)$', raw, re.M)
if line:
value = line.group(1)
if value[:1] not in ('"', "'", '>', '|') and ': ' in value:
fail.append('%s: unquoted description contains ": ", which a YAML '
'parser rejects; quote the value' % path)
return
try: try:
ast.parse(open(path, encoding='utf-8').read()) data = yaml.safe_load(raw)
except SyntaxError as e: except yaml.YAMLError as exc:
fail.append('%s: syntax error %s' % (path, e)) detail = str(exc).splitlines()[0]
print(' FAIL %s' % path) fail.append('%s: frontmatter is not valid YAML (%s); the skills CLI '
continue 'skips a skill whose frontmatter will not parse'
r = subprocess.run([sys.executable, '-B', path, '--help'], % (path, detail))
capture_output=True, text=True, timeout=60) return
# A script may exit non-zero on --help if it uses the "print usage and exit 2" if not isinstance(data, dict):
# convention. That is acceptable; a traceback is not. fail.append('%s: frontmatter is not a YAML mapping' % path)
crashed = 'Traceback (most recent call last)' in (r.stderr or '') return
helped = bool((r.stdout or '').strip()) or bool(r.stderr)
ok = (not crashed) and helped
verdict = 'ok' if r.returncode == 0 and not crashed else \
('usage-ok rc=%d' % r.returncode if ok else 'CRASH rc=%d' % r.returncode)
print(' %-32s %s' % ('%s --help' % name, verdict))
if not ok:
fail.append('%s --help crashed: %s' % (path, (r.stderr or '')[:300]))
print("\n== markdown references resolve ==") got = data.get('name')
refs = set(os.listdir('references')) if not got:
scripts = set(os.listdir('scripts')) fail.append('%s: frontmatter has no `name`' % path)
missing = [] elif str(got) != name:
for name in ['SKILL.md', 'README.md'] + ['references/' + f for f in refs if f.endswith('.md')]: fail.append('%s: frontmatter name %r does not match directory %r'
text = open(name, encoding='utf-8').read() % (path, got, name))
for m in re.finditer(r'`?(references/[A-Za-z0-9_\-]+\.md)', text): elif not NAME_RE.match(str(got)) or len(str(got)) > 64:
target = m.group(1).split('/')[1] fail.append('%s: name %r violates the spec (lowercase alphanumerics and '
if target not in refs: 'single hyphens, max 64 chars)' % (path, got))
missing.append('%s -> %s' % (name, m.group(1)))
for m in re.finditer(r'`?(scripts/[A-Za-z0-9_\-]+\.(?:py|js))', text):
target = m.group(1).split('/')[1]
if target not in scripts:
missing.append('%s -> %s' % (name, m.group(1)))
if missing:
for x in sorted(set(missing)):
print(' MISSING %s' % x)
fail.extend(missing)
else:
print(' all referenced reference/script paths exist')
print("\n== every reference file is reachable from SKILL.md or README.md ==") desc = data.get('description')
bodies = open('SKILL.md', encoding='utf-8').read() + open('README.md', encoding='utf-8').read() if not desc:
unlisted = [f for f in sorted(refs) if f.endswith('.md') and f not in bodies] fail.append('%s: frontmatter has no `description`' % path)
for f in unlisted: else:
print(' UNLISTED references/%s' % f) desc = str(desc)
if unlisted: if len(desc) > 1024:
fail.append('unlisted references: %s' % unlisted) fail.append('%s: description is %d chars (spec max 1024)'
% (path, len(desc)))
elif len(desc) < 40:
fail.append('%s: description is only %d chars, too short to tell an '
'agent when this skill applies' % (path, len(desc)))
print("\n== every script is mentioned somewhere in the docs ==")
all_docs = bodies
for f in refs:
if f.endswith('.md'):
all_docs += open('references/' + f, encoding='utf-8').read()
undoc = [s for s in sorted(scripts) if s.endswith(('.py', '.js')) and s not in all_docs]
for s in undoc:
print(' UNDOCUMENTED scripts/%s' % s)
if undoc:
fail.append('undocumented scripts: %s' % undoc)
print("\n== result: %d problem(s) ==" % len(fail)) def check_scripts(skill_dir):
for f in fail: scripts = os.path.join(skill_dir, 'scripts')
print(' - %s' % f) if not os.path.isdir(scripts):
sys.exit(1 if fail else 0) return
for name in sorted(os.listdir(scripts)):
if not name.endswith('.py'):
continue
path = os.path.join(scripts, name)
try:
ast.parse(read(path))
except SyntaxError as exc:
fail.append('%s: syntax error %s' % (path, exc))
print(' FAIL %s' % name)
continue
run = subprocess.run([sys.executable, '-B', path, '--help'],
capture_output=True, text=True, timeout=60,
cwd=ROOT)
# A script may exit non-zero on --help under the "print usage, exit 2"
# convention. That is acceptable; a traceback is not.
crashed = 'Traceback (most recent call last)' in (run.stderr or '')
helped = bool((run.stdout or '').strip()) or bool(run.stderr)
ok = (not crashed) and helped
verdict = 'ok' if run.returncode == 0 and not crashed else \
('usage-ok rc=%d' % run.returncode if ok else
'CRASH rc=%d' % run.returncode)
print(' %-32s %s' % (name, verdict))
if not ok:
fail.append('%s --help crashed: %s' % (path, (run.stderr or '')[:300]))
def check_references(name, skill_dir, root_docs):
"""Resolve in-skill references, and confirm every file is reachable."""
ref_dir = os.path.join(skill_dir, 'references')
script_dir = os.path.join(skill_dir, 'scripts')
refs = set(os.listdir(ref_dir)) if os.path.isdir(ref_dir) else set()
scripts = set(os.listdir(script_dir)) if os.path.isdir(script_dir) else set()
# Documents whose relative paths are rooted at the skill directory.
own = [os.path.join(skill_dir, 'SKILL.md')]
own += [os.path.join(ref_dir, f) for f in sorted(refs) if f.endswith('.md')]
missing = []
for doc in own:
if not os.path.exists(doc):
continue
text = read(doc)
for m in REF_RE.finditer(text):
if m.group(1).split('/')[1] not in refs:
missing.append('%s -> %s' % (os.path.relpath(doc, ROOT), m.group(1)))
for m in SCRIPT_RE.finditer(text):
if m.group(1).split('/')[1] not in scripts:
missing.append('%s -> %s' % (os.path.relpath(doc, ROOT), m.group(1)))
if missing:
for x in sorted(set(missing)):
print(' MISSING %s' % x)
fail.extend(missing)
# Reachability: nothing may be orphaned from the skill entry point.
bodies = read(os.path.join(skill_dir, 'SKILL.md')) + root_docs
for f in sorted(refs):
if f.endswith('.md') and f not in bodies:
fail.append('%s: references/%s is not mentioned anywhere' % (name, f))
print(' UNLISTED references/%s' % f)
all_docs = bodies
for f in sorted(refs):
if f.endswith('.md'):
all_docs += read(os.path.join(ref_dir, f))
for s in sorted(scripts):
if s.endswith(('.py', '.js')) and s not in all_docs:
fail.append('%s: scripts/%s is undocumented' % (name, s))
print(' UNDOCUMENTED scripts/%s' % s)
def check_readme():
"""README paths must be explicit and must exist."""
text = read(README)
if not os.path.isdir(SKILLS_DIR):
fail.append('no skills/ directory at the repository root')
return
skills = {d for d, _ in discover_skills()}
named = set()
for m in NAMED_PATH_RE.finditer(text):
named.add(m.group(0))
skill, sub, fname = m.group('skill'), m.group('sub'), m.group('file')
if skill not in skills:
fail.append('README.md: names unknown skill %r' % skill)
print(' UNKNOWN SKILL skills/%s' % skill)
elif not os.path.exists(os.path.join(SKILLS_DIR, skill, sub, fname)):
fail.append('README.md: %s does not exist' % m.group(0))
print(' MISSING %s' % m.group(0))
# Bare relative paths are ambiguous once there is more than one skill, so the
# README must not use them at all.
stripped = NAMED_PATH_RE.sub('', text)
for m in BARE_PATH_RE.finditer(stripped):
fail.append('README.md: bare path %r should be skills/<name>/%s'
% (m.group(0), m.group(0)))
print(' BARE PATH %s' % m.group(0))
def main():
skills = discover_skills()
print('== skills discovered ==')
if not skills:
print(' none (expected at least skills/<name>/SKILL.md)')
for name, _ in skills:
print(' skills/%s' % name)
print('\n== frontmatter ==')
for name, skill_dir in skills:
check_frontmatter(name, skill_dir)
print(' %d checked' % len(skills))
root_docs = read(README)
for name, skill_dir in skills:
print('\n== %s: python syntax + --help ==' % name)
check_scripts(skill_dir)
print('\n== %s: reference and script paths resolve ==' % name)
before = len(fail)
check_references(name, skill_dir, root_docs)
if len(fail) == before:
print(' all referenced reference/script paths exist')
print('\n== README paths are explicit and exist ==')
check_readme()
print('\n== result: %d problem(s) ==' % len(fail))
for f in fail:
print(' - %s' % f)
return 1 if fail else 0
if __name__ == '__main__':
sys.exit(main())
+53 -2
View File
@@ -1,6 +1,6 @@
--- ---
name: apk-reverse name: apk-reverse
description: Reverse engineer, debloat, de-ad, patch, or re-sign Android APKs, and analyze their runtime and server-side behavior. Use when a task involves an .apk/.aab/.dex/.so sample, smali or dex patching, Frida/objection runtime hooking, repacking and re-signing, removing ads or SDK trackers, probing a mobile app's HTTP API, or deciding whether a client-side patch is even capable of achieving the goal. Covers recon, anti-tamper, ad removal, membership/paywall limits, dex-level surgical patching, repack pitfalls, device and emulator setup, and a hard-won failure catalogue. Load the body before planning any patch work: it opens with a symptom index and four gates that must be cleared first. description: "Reverse engineer, debloat, de-ad, patch, or re-sign Android APKs, and analyze their runtime and server-side behavior. Use when a task involves an .apk/.aab/.dex/.so sample, smali or dex patching, Frida/objection runtime hooking, repacking and re-signing, removing ads or SDK trackers, probing a mobile app's HTTP API, or deciding whether a client-side patch is even capable of achieving the goal. Covers recon, anti-tamper, ad removal, membership/paywall limits, dex-level surgical patching, repack pitfalls, device and emulator setup, and a hard-won failure catalogue. Load the body before planning any patch work: it opens with a symptom index and four gates that must be cleared first."
--- ---
# APK Reverse Engineering & Patching # APK Reverse Engineering & Patching
@@ -49,6 +49,57 @@ native, Dart/Unity, server). Patching the wrong layer either does nothing or bre
patch "had no effect", the diagnosis was wrong — go back to classification instead of patching harder. patch "had no effect", the diagnosis was wrong — go back to classification instead of patching harder.
→ `references/recon.md`, then the layer-specific file the symptom index points at → `references/recon.md`, then the layer-specific file the symptom index points at
## Coverage — what this skill claims, and what it does not
The failure this section prevents is not ignorance. It is **a confident wrong answer produced by
applying the nearest available procedure to a target it was never written for.** A documented method
that almost fits is more dangerous than no method at all, because it arrives with a plan, a
vocabulary and a set of reassuring numbers.
**Covered, by verified mechanisms:**
- Client-side ads, promos and splash/popup/tab configuration — including the server-issued UI config
that has no SDK to find (`ad-removal.md`, `server-config-and-updates.md`).
- Deciding whether a membership, paywall or feature gate is *client-enforceable* at all, and saying
so plainly when it is not (`membership-and-limits.md`, `account-gates.md`).
- dex-level surgical patching: equal-length byte edits and dexlib2 method rewrites, plus the header,
verifier and alignment rules that decide whether the build loads at all (`dex-patching.md`,
`byte-level-patching.md`, `patch-audit.md`).
- Repacking, signing, installing, and the install refusals that look like a broken build
(`repack-and-sign.md`).
- Packers, custom loaders and code virtualization: identifying them, measuring the validation
boundary, and the routes that survive it (`packers.md`,
`code-virtualization-and-custom-linkers.md`).
- The native layer: `.so` hosts, tamper-triggered self-termination, forged ELF structure, and
neutralising a terminate path without freezing the process (`native-and-so.md`,
`native-tamper-and-suicide.md`).
- Flutter / Dart AOT: pinning the engine version, decoding the object pool, locating and patching
business logic inside `libapp.so` (`dart-aot.md`).
- Runtime analysis with Frida, server-side API probing, feature-scoped TLS failures, update and
forced-upgrade neutralisation, and the verification discipline everything above rests on.
**Not covered — say so rather than improvise:**
- **Unity / IL2CPP logic recovery.** `framework-runtimes.md` identifies the runtime and establishes
that the dex is not the battlefield; it does not carry the IL2CPP equivalent of `dart-aot.md`.
There is no verified recipe here for locating a method inside `libil2cpp.so` plus
`global-metadata.dat`.
- **React Native / Hermes bytecode** and Cordova/hybrid internals, beyond runtime identification and
the generic "find the string, then find what references it" approach.
- **iOS / `.ipa` of any kind.** Every device, signing and packaging instruction here is Android.
- **Defeating a server-side authority.** `server-api.md` exists to determine *who owns a gate*, not
to break an authorization the server performs.
- **A general unpacker, or an anti-detection arms race.** `detection-and-anti-analysis.md` decides by
cost and often concludes "switch to static"; it is not a catalogue of evasion for every detector
you might meet.
**The fallback, as an instruction:** if the target does not match that list, or no symptom-index row
matches, **stop and classify before choosing a branch.** Answer the thirteen questions first. If the
shape still does not fit — an unknown runtime, a mechanism you cannot name — say exactly that, and
propose the cheapest experiment that would identify it, rather than taking the closest documented
route and applying it anyway. A wrong branch here does not fail loudly: it produces an artifact that
builds, runs, and does the wrong thing.
## Symptom index — a matching row is a stop signal ## Symptom index — a matching row is a stop signal
You arrive at a symptom, not at a file name. Each row below is a failure that has already been paid You arrive at a symptom, not at a file name. Each row below is a failure that has already been paid
@@ -128,7 +179,7 @@ Answer these before touching a tool. Every one of them changes the whole plan.
- Feature flag, UI gate, debug switch → usually client-side - Feature flag, UI gate, debug switch → usually client-side
- Anything decided by an API response → server-side → `references/server-api.md` - Anything decided by an API response → server-side → `references/server-api.md`
3. **Is the app's own code in plain dex, or moved to native/Flutter/Unity?** 3. **Is the app's own code in plain dex, or moved to native/Flutter/Unity?**
Plain dex → you can patch. Flutter (`libflutter.so` + `libapp.so`) / Unity (`libil2cpp.so`) / pure native → different toolchain entirely. See `references/recon.md` §code-location and `references/framework-runtimes.md`. Plain dex → you can patch. Flutter (`libflutter.so` + `libapp.so`) / Unity (`libil2cpp.so`) / pure native → different toolchain entirely. See `references/recon.md` §Where does the app's own code live and `references/framework-runtimes.md`.
**Runtime check (cheap -- do it before committing to a layer):** hook the obvious Java classes for the UI you care about, then reproduce that UI. If those hooks fire, the behavior is Java-owned. If they fire **zero times** while the UI is plainly on screen, the behavior is drawn by the runtime or by native code, and a dex-only plan will stall. Do not keep hunting in dex after a zero-hit probe -- that is the most expensive wrong turn in this skill's history. **Runtime check (cheap -- do it before committing to a layer):** hook the obvious Java classes for the UI you care about, then reproduce that UI. If those hooks fire, the behavior is Java-owned. If they fire **zero times** while the UI is plainly on screen, the behavior is drawn by the runtime or by native code, and a dex-only plan will stall. Do not keep hunting in dex after a zero-hit probe -- that is the most expensive wrong turn in this skill's history.
4. **What must the deliverable be able to do?** Write the answer as a testable sentence before 4. **What must the deliverable be able to do?** Write the answer as a testable sentence before
planning anything, then re-read it at every checkpoint. This is the drift guard, and the drift it planning anything, then re-read it at every checkpoint. This is the drift guard, and the drift it
@@ -36,7 +36,7 @@ in this skill's history.
3. **A shipping library whose SONAME does not match its filename.** A private loader is often 3. **A shipping library whose SONAME does not match its filename.** A private loader is often
named for its role while sitting under an innocuous filename. Read `DT_SONAME` from named for its role while sitting under an innocuous filename. Read `DT_SONAME` from
`.dynamic`, not the file name — `probe_deps`-style parsing of `PT_DYNAMIC` (see `.dynamic`, not the file name — `probe_deps`-style parsing of `PT_DYNAMIC` (see
`native-and-so.md` §forged headers) gives you the truth. A `lib<something>.so` claiming `native-tamper-and-suicide.md` §Forged section headers) gives you the truth. A `lib<something>.so` claiming
`soname = liblinkerloader.so` is a private ELF loader. `soname = liblinkerloader.so` is a private ELF loader.
Supporting tells: `assets/` carrying a small text descriptor naming the protector and its version; Supporting tells: `assets/` carrying a small text descriptor naming the protector and its version;
@@ -337,7 +337,7 @@ Java.perform(function () {
}); });
``` ```
Read the log after driving the UI. `RAW-URL` (with no `OKHTTP` events) is how you learn the request went over `HttpsURLConnection` — a different trust path entirely (`references/server-api.md` § TLS). `THROW` is how you learn an upper layer swallowed the real error. Read the log after driving the UI. `RAW-URL` (with no `OKHTTP` events) is how you learn the request went over `HttpsURLConnection` — a different trust path entirely (`references/tls-and-cert.md` §If OkHttp is the failing path). `THROW` is how you learn an upper layer swallowed the real error.
```bash ```bash
# pull the record (app-private path needs root to read) # pull the record (app-private path needs root to read)
@@ -357,7 +357,7 @@ adb -s <serial> shell "su -c 'cat /data/user/0/<app.package>/files/probe.log'" >
}); });
``` ```
3. **Is that code path reached at all?** Layer 4 settles it: if `THROW` events show the app failing earlier, your hook's call site is never executed and no amount of hooking will help. 3. **Is that code path reached at all?** Layer 4 settles it: if `THROW` events show the app failing earlier, your hook's call site is never executed and no amount of hooking will help.
4. **Did your UI action trigger business logic?** A tap that looks fine but fails a local form check returns before any network call. Verify the input actually reached the field (`references/environment.md` § UI automation), not just that the button animated. 4. **Did your UI action trigger business logic?** A tap that looks fine but fails a local form check returns before any network call. Verify the input actually reached the field (`references/environment.md` §Driving the UI from adb), not just that the button animated.
## `Java.choose` also matches dead instances ## `Java.choose` also matches dead instances
@@ -4,9 +4,10 @@ Load this when you are choosing tools, when a tool produces an answer that smell
something is not installed. It is a map, not a tutorial: each entry says what the tool is *for*, how something is not installed. It is a map, not a tutorial: each entry says what the tool is *for*, how
to drive it non-interactively, and the failure mode that wastes time. to drive it non-interactively, and the failure mode that wastes time.
**Prefer tools you can drive from a command line.** An agent cannot click. Tools that exist only as **Prefer tools you can drive from a command line.** An agent cannot click. A tool that ships only a
a GUI are listed where they matter, marked as requiring a human, so you can ask for that instead of GUI is not automatically out of reach — check for a headless or MCP path first, which is an
stalling or silently substituting a weaker method. install-and-set-up step rather than a reason to substitute the tool. When no such route exists, ask
for a human instead of stalling or silently downgrading to a weaker method.
## Tier 0 — present on almost any machine, no install ## Tier 0 — present on almost any machine, no install
@@ -99,7 +100,7 @@ not that the method is empty.
| `capstone` (python) | library | decoding in your own scripts — see the silent-stop trap below | | `capstone` (python) | library | decoding in your own scripts — see the silent-stop trap below |
| `keystone` (python) | library | assembling a short patch when you are editing bytes by hand | | `keystone` (python) | library | assembling a short patch when you are editing bytes by hand |
| `pyelftools` (python) | library | **section-based — unreliable on hardened targets.** Prefer hand-walking `PT_LOAD`/`PT_DYNAMIC`, as `scripts/elf_plt.py` does | | `pyelftools` (python) | library | **section-based — unreliable on hardened targets.** Prefer hand-walking `PT_LOAD`/`PT_DYNAMIC`, as `scripts/elf_plt.py` does |
| IDA Pro | GUI | human-driven. Ask for it when decompiler output is genuinely needed; do not pretend a raw disassembly is equivalent | | IDA Pro | GUI, **or headless via `idalib-mcp`** | the strongest decompiler output. Do not assume it needs a human in front of it — a headless MCP server exists (see *MCP tool servers* below). Ask for a human only when that setup is genuinely unavailable |
| `x64dbg` | GUI, Windows | human-driven live debugging of a native Windows target | | `x64dbg` | GUI, Windows | human-driven live debugging of a native Windows target |
| `gdb` / `lldb` | CLI | live debugging where a device or emulator allows it | | `gdb` / `lldb` | CLI | live debugging where a device or emulator allows it |
@@ -112,6 +113,64 @@ completeness matters (`native-tamper-and-suicide.md` §Scanner traps).
4-byte window into *some* instruction, so a wrong start offset yields plausible-looking garbage. 4-byte window into *some* instruction, so a wrong start offset yields plausible-looking garbage.
Bound your window with a known entry point or a known call site. Bound your window with a known entry point or a known call site.
## MCP tool servers — an external dependency, not a tool on the shelf
An MCP server is neither a CLI tool nor one of the scripts here. It is a **separate installation plus
a running process**: something to install, a service to start, usually an extension to load, often an
authorization step. Listing one beside `readelf` would imply it is already present. Declare it as a
dependency with its prerequisites, and check whether it is actually available *before* planning work
that needs it — discovering this mid-task is the "missing tool" failure again.
### IDA Pro, headless
IDA does **not** require a human at a GUI. `mrexodia/ida-pro-mcp` ships `idalib-mcp`, a headless MCP
server that drives an IDA database with no GUI process at all:
```sh
uv run idalib-mcp --host 127.0.0.1 --port 8745 path/to/executable # open a binary up front
uv run idalib-mcp --host 127.0.0.1 --port 8745 # open databases on demand
uv run idalib-mcp --stdio # for stdio-based clients
```
Prerequisites, all of which must be arranged before the first call: IDA Pro 8.3+ (9 recommended;
**IDA Free is not supported**), a Python 3.11+ that `idapyswitch` can select, `uv`, and an `idalib`
activated globally via `py-activate-idalib.py`. Each open database lives in a worker process that
outlives the supervisor and is adopted transparently by a later supervisor on the same host, so
several sessions can share one analysis. Every tool call carries an explicit `database` argument —
there is no implicit "current database" — and `idb_open` returns the session id you must pass.
The GUI-plugin variant of the same project is deprecated upstream in favour of `idalib-mcp`; do not
set that up for agent work.
### Ghidra
`LaurieWired/GhidraMCP` is a Ghidra **extension** plus a separate Python bridge, not a headless
analysis server: Ghidra must be running with the plugin loaded (its HTTP server defaults to
`127.0.0.1:8080`) and the bridge is another process your client starts. Treat it as requiring an
interactive GUI session. For unattended work the shape that actually runs without a GUI is
`analyzeHeadless` with a post-script.
### The boundary: deobfuscate before you hand the binary to a model
Both are LLM-driven, and they inherit the model's weaknesses. The IDA MCP authors say it plainly in
their own README: **"LLMs will not perform well on obfuscated code"**, and they advise removing
string encryption, import hashing, control flow flattening, code encryption and anti-decompilation
tricks *before* asking a model to solve anything.
That matters more here than in most domains, because it is precisely the shape of the targets this
skill deals with. An OLLVM-style or virtualized binary is not a job for a model reading decompiler
output; the deobfuscated version is a *different binary*, and producing it is the work already
described in `code-virtualization-and-custom-linkers.md` and
`native-tamper-and-suicide.md`. The same advice has a second half: resolve library code first
(FLIRT/Lumina) so the model is not asked to reason about `memcpy` as if it were the program.
Two consequences worth carrying into the plan:
- Ask a model for **navigation and reading** — rename, comment, summarise a function, convert an
immediate — and verify anything that decides the patch against the disassembly yourself.
- If the server is not installed, that is a **missing dependency to report**, not a licence to present
a weaker tool's output as equivalent (`packers.md`).
## Tier 3 — runtime and network ## Tier 3 — runtime and network
| Tool | Invoke | Notes | | Tool | Invoke | Notes |
@@ -22,7 +22,7 @@ to updates:
A fourth case is a **resource / hot-update channel**: the app fetches a bundle (JS, a Dart/RN patch, a A fourth case is a **resource / hot-update channel**: the app fetches a bundle (JS, a Dart/RN patch, a
config blob, a plugin APK) that can reintroduce the behaviour you removed. That is a separate code path config blob, a plugin APK) that can reintroduce the behaviour you removed. That is a separate code path
from version checking — handle it explicitly if the app has one (see §hot-update below). from version checking — handle it explicitly if the app has one (see §Step 5 below).
## Step 1: locate the check ## Step 1: locate the check
@@ -36,7 +36,7 @@ update / upgrade / new version / check version / force update
``` ```
Search those in the app's string table and in class names; on heavy runtimes search the string pool, Search those in the app's string table and in class names; on heavy runtimes search the string pool,
remembering its encoding (see `framework-runtimes.md` §string encoding — searching only one encoding remembering its encoding (see `framework-runtimes.md` §Locating logic without symbols — searching only one encoding
misses half your hits, and "nothing found" is usually the encoding, not an absence). misses half your hits, and "nothing found" is usually the encoding, not an absence).
**Signals that a version check exists even if you cannot read the strings:** **Signals that a version check exists even if you cannot read the strings:**
@@ -143,7 +143,7 @@ If the app can fetch a bundle at runtime, version checking is not the only way y
Note the flip side: a **server-issued configuration** is the same category. If the thing you removed was Note the flip side: a **server-issued configuration** is the same category. If the thing you removed was
driven by remote config, the client patch holds only while the server keeps sending that config — see driven by remote config, the client patch holds only while the server keeps sending that config — see
`references/ad-removal.md` §server-driven and `references/membership-and-limits.md` for the general `references/server-config-and-updates.md` §6 and `references/membership-and-limits.md` for the general
rule that *client-side enforcement can be patched, server-side authority cannot*. rule that *client-side enforcement can be patched, server-side authority cannot*.
## The honest answer for silent version gates ## The honest answer for silent version gates