37 KiB
Extension verification — truthful environment gates, documented-command validation, and APK rebuild safety
Covers the P0 pass that replaced the optimistic half of skills/apk-reverse/scripts/doctor.py
with a probed capability registry, added check_commands.py at the repository root, and — in the
same round, by teammate zip-safety — replaced the APK-rebuild writer inside
skills/apk-reverse/scripts/so_constpatch.py. Strength labels are the ones this directory's
README defines: observed (reproduced here, exact command and output recorded), inferred
(follows from observed facts, the step itself was not executed), unverified (assumed or
reported elsewhere, not independently reproduced).
Two gates depend on the material below, which is why the report was allowed to become worse
looking: G2 (environment gating) reads the capability verdicts, and the documented-command check
is what keeps a reader from following a page instead of the tool. "Documented command" below means
a script invocation written in a code block or inline backtick in a markdown file — the shape
check_commands.py extracts and validates.
Reference environment for this pass: Windows 11 / AMD64, Python 3.14.0, JDK 17.0.4.1
(java/javac only, on the Oracle launcher shim path), adb 1.0.41 with one attached
arm64-v8a device at API 30, Frida 16.7.19 host package, capstone present, Android
build-tools unpacked at a local directory but not on PATH. Workbench artifacts live in
tools/_work/truth-gates/ and tools/_work/zip-safety-out/ (not part of the skill; tools/ is
gitignored, so those paths are an operation record rather than something a reader can open).
0. Summary of what this pass established
| Claim | Label |
|---|---|
The committed doctor.py registered 28 scripts against 51 files actually in scripts/ (46 .py + 5 .js) |
observed |
Its capability table derived repack + sign and smali round-trip from java alone; on a JDK-only PATH it reported both as OK while keytool, jarsigner, zipalign, apksigner and all eight smali jars were absent |
observed |
The rebuilt doctor.py scans the directory, so registered/checked = N / N equals the file count; on this host 52 / 52 (47 .py + 5 .js, the new capabilities.py included) |
observed |
Capability verdicts now come from a real requires closure with an executable next action and a cost estimate; 16 capabilities are resolved, and the same JDK-only host reports repack_signed and smali_roundtrip BLOCKED |
observed |
check_commands.py reads each script's argparse interface with ast (no script executed to learn its interface) and validates 139 documented commands across 76 markdown files against 39 interfaces, with 0 drift after the one known drift was fixed |
observed |
The drift the checker was written for — sig_probe.py --live --pkg <pkg> — is reproduced from git show HEAD: of the pre-fix page and is reported at the right line |
observed |
The pre-existing so_constpatch.py APK writer dropped per-entry zip metadata and lost resources.arsc alignment; the replacement keeps metadata byte-for-byte and the alignment gate passes |
observed |
zipalign -c -v 4 from the Android build-tools agrees with that verdict independently (old writer rc=1 FAILED, new writer rc=0), re-checked in this pass on artifacts produced by the other teammate |
observed |
repack.py:_needs_alignment uses ^lib/[^/]+\.so$, which never matches the real lib/<abi>/x.so layout, so its alignment filler is dead code on real APKs |
observed (reported, not fixed — outside every current write scope) |
A real pm install of any rebuilt artifact |
not done — no install was attempted in this pass |
1. The doctor was optimistic, and a gate trusted it
1.1 A hand-written inventory that could only go stale
The committed version carried script_dependencies() returning a literal dict of 28 entries. The
directory held 51 files. The 23 absent from the table were not peripheral: they included
dex_patch_bytes.py, dex_find_insn.py, dex_dump_validate.py, dex_mem_scan.py, dexutil.py,
protobuf_decode_raw.py, java2c_probe.py, vmp_diff_harness.py and scan_leaks.py — nine of the
scripts this skill exists for. The summary line read scripts runnable here: 28 / 28, so the report
looked like complete coverage of a set that was less than half the set.
The replacement cannot drift in that direction, because the list is the directory:
$ python skills/apk-reverse/scripts/doctor.py --scripts
--- scripts ---
directory: E:\apk-reverse\skills\apk-reverse\scripts
registered/checked = 52 / 52 (.py=47, .js=5)
ok=33 partial=13 blocked=6 unknown=0
A file whose dependency set cannot be decided is printed as UNKNOWN and still counted in the
denominator; unknown=0 here is a measurement, not an assumption. Dependencies come from an ast
pass over imports checked against sys.stdlib_module_names, plus a narrow scan for host tools
(resolve_tool('java'), the program of a subprocess list) against a known-tool set. Modules are
never imported to probe them, because importing the probe target is itself a side effect — frida
starts threads and enumerates devices at import time.
1.2 The formula that ignored the toolchain — decisive reproduction
doctor.py:319-320 (committed):
caps['repack + sign'] = bool(tools['java']['path']) and py['ok']
caps['smali round-trip'] = bool(tools['java']['path']) and py['ok']
Neither ability follows from java. To show that the mechanism was wrong rather than merely lucky,
the committed script was extracted from git and run with a PATH holding a JDK and nothing else:
$ git show HEAD:skills/apk-reverse/scripts/doctor.py > tools/_work/truth-gates/doctor-HEAD.py
$ set PATH=C:\Program Files\Common Files\Oracle\Java\javapath;C:\Windows\System32;C:\Windows;C:\Python314
$ python tools/_work/truth-gates/doctor-HEAD.py
--- capabilities ---
[OK ] static (dex/zip/strings)
[OK ] repack + sign <-- no keytool, no zipalign, no signer on this PATH
[OK ] smali round-trip <-- none of the eight jars exist on this host
[OK ] native ELF patching
[-- ] jadx decompile
...
The rebuilt script, on the same restricted PATH:
[BLOCKED] repack_signed Repack, align and sign an APK
missing : tool:keytool -- not on PATH or APKREV_TOOLS
next: the directory on PATH (C:\Program Files\Common Files\Oracle\Java\javapath)
is a launcher shim, not a full JDK bin/: put %JAVA_HOME%\bin first, or
install a JDK - a JRE ships no keytool [~2 min, unverified]
missing : tool:zipalign -- not on PATH or APKREV_TOOLS
next: build-tools are already unpacked here but are not on the search path:
set APKREV_TOOLS=E:\tools\android-14;E:\tools\bin (found: ...) [~0 min, observed]
missing : artifact:signer -- tool:apksigner (not on PATH or APKREV_TOOLS);
jar:uber-apk-signer (uber-apk-signer.jar not found)
reduced : tool:jarsigner -- not on PATH or APKREV_TOOLS
[BLOCKED] smali_roundtrip Smali disassemble / assemble round-trip
[OK ] repack_unsigned Repack an APK without signing (--no-sign path)
ok=5 partial=1 blocked=10 (of 16 capabilities)
repack_unsigned staying OK is deliberate and true: repack.py --no-sign is zipfile plus the
STORED-entry rules and needs no Java tooling at all. A gate that reported the whole file unusable
would have been a pessimism of its own.
1.3 The closure is the point, not the atom list
capabilities.py is the single registry: 16 capabilities, each a name, a note, the scripts it
serves, required atoms, optional atoms, and depends_on capabilities that are expanded
recursively. repack_signed depends on repack_unsigned, so it cannot be OK while the repacking it
is built on is not. Atoms cover the four kinds that actually decide something here: an importable
module, a tool resolvable on PATH or APKREV_TOOLS, a jar set, a device property (present,
rooted, frida-server running), a repository file, and one input artifact that no host can
install — the Dart AOT snapshot dump, which is why dart_aot_full reports BLOCKED with
build blutter … or run the pinned aotopsy front end rather than a download link.
status ∈ ok | partial | blocked. A missing required atom blocks. A missing optional atom
degrades: static_native stays usable without capstone and names the part that is not
(svc_scan.py refuses to run without it; the caller index in dart_disasm.py works).
Two probes were worth getting right rather than guessing:
- A JDK whose launcher shim is on
PATH(...\Oracle\Java\javapath) exposesjavaandjavacbut notkeytoolorjarsigner. The registry probes those separately and, on failure, says the shim directory is a shim instead of reporting a missing JDK. - Build-tools present but unexposed are found by searching the usual SDK locations plus a
conventional
tools/directory, and the next action becomes the exact assignment (set APKREV_TOOLS=E:\tools\android-14;E:\tools\bin) with a measured cost of~0 min, rather than "install build-tools".
That last hint was verified end to end: with APKREV_TOOLS set, repack_signed loses the
zipalign and signer blockers and keeps only keytool — which is the honest residual on this
host, because the JDK really is a shim install.
1.4 Cost estimates carry their own strength
Every install hint carries est_minutes with a basis. observed means measured in this
repository: the --unsafe-rebuild-independent blutter build is ≈78 s end to end
(TOOL-VERDICTS.md), and exposing an already-unpacked build-tools directory is 0 minutes because
the search found it. Everything else is labelled unverified and is a planning aid only: a guessed
ten minutes printed as a measurement is the same class of error as a capability verdict derived
from the wrong tool.
Per-capability est_basis is mixed when the missing atoms disagree, which is what makes
repack_signed read ~12 min, mixed rather than an authoritative-looking single number.
1.5 Exit codes and the token
doctor.py and capabilities.py both follow this round's convention: 0 = nothing blocked
(partial does not fail a gate, and the counts are printed so it cannot hide), 3 = at least one
capability blocked, 2 = usage, 4 = internal. The last line is RESULT=env_ok /
env_partial / env_blocked / usage_error / internal_error, and the usage-error path keeps the
token too — argparse would otherwise exit 2 with nothing a caller can branch on.
--json carries {status, exit_code, capability?, evidence[], warnings[], next_action?} plus, for
compatibility, the keys the old report used (python, tools, jars, device, capabilities,
scripts). capabilities changed shape on purpose — the old flat {name: bool} map was the
optimism — and a capabilities_legacy map gives the same information as {id: "ok"|"partial"|"blocked"}
for any consumer that only wants the verdict.
2. check_commands.py — the commands a reader is told to run
2.1 What it reads, and from where
check_repo.py proves that a referenced path resolves and every script answers --help. Neither
can see a documented command whose flags no longer exist, and the repository shipped exactly one for
a long time. check_commands.py closes that gap by reading each script's real interface with ast:
ArgumentParser()/add_argument()calls are parsed statically, so no script is executed to learn its interface;add_subparsers()/add_parser('name')are followed, and a sub-parser's flags are attributed to that sub-command rather than merged into the parent (one variable reused across several sub-parsers is handled, and the parameters are then attributed to the group as a whole);action=store_true|store_false|count|help|versionandnargs=0mean the flag consumes no value;- a script with no
argparseat all (a hand-rolledsys.argvparser:smtool.py,dex_strpatch.py) falls back to parsing its own--helpoutput, andsmtool.py's--cpis recovered that way. The probe runs in a throwaway directory, because a probe must not leave anything behind — a script that reads its first argument as an output directory would otherwise create one named after the flag.
Documented commands are extracted from fenced code blocks and inline backticks in README.md,
skills/**/*.md and docs/**/*.md. A documented line is split on &&, ||, ; and |, and a
redirection truncates the segment: python check_repo.py && python check_refs.py is two commands,
not one with two extra positionals. <PKG>-style placeholders are kept as single tokens, because
reading <pkg> as a shell input redirect truncates the command at --pkg — and a truncated
command checks clean, which is how the checker would have missed the one drift it was built for.
2.2 Allowance rules that keep it honest rather than noisy
- argparse accepts a unique prefix of a long option, so
--maxpasses for--max-depth; an ambiguous prefix is reported. -h/--helpare always accepted.- A flag that needs a value does not consume an option-looking token; argparse reports
"expected one argument" instead. This is the rule that makes
--live --pkg <pkg>report--pkgrather than swallow it as the value of--live. - Positional counts are computed from the declared positionals (with
nargs=None|int|'?'|'*'|'+') and are reported as warnings, never as drift: a static count can be thrown off by a placeholder or an elision, so it does not get to fail a gate. It is skipped entirely when any option has a variablenargsor the interface came from--help.
2.3 The drift it exists for, reproduced from git
The page was already fixed by the time this checker ran, so the pre-fix revision is taken from git and checked as a file:
$ git show HEAD:skills/apk-reverse/references/signature-derived-keys.md > tools/_work/truth-gates/sig-derived-HEAD.md
$ python check_commands.py --fix-report --path tools/_work/truth-gates/sig-derived-HEAD.md
tools\_work\truth-gates\sig-derived-HEAD.md:61 sig_probe.py unknown flag --pkg closest known: --apk cmd: python scripts/sig_probe.py --live --pkg <pkg>
RESULT=commands_drift
Line 61, the right script, and the correct suggestion (sig_probe.py takes --live PKG and has
never had --pkg). A second, synthetic fixture proves the check is not a single-rule special case:
$ python check_commands.py --fix-report --path tools\_work\truth-gates\NEGATIVE-unknown-flag.md
...:4 sig_probe.py unknown flag --pkg closest known: --apk cmd: python skills/apk-reverse/scripts/sig_probe.py --live --pkg <pkg>
...:5 dex_classdiff.py unknown flag --original closest known: --max-list cmd: ... --original a.dex --patched b.dex
...:5 dex_classdiff.py unknown flag --patched closest known: --max-list cmd: ... --original a.dex --patched b.dex
...:7 skills/apk-reverse/scripts/does_not_exist.py unknown flag missing-script closest known: - cmd: python .../does_not_exist.py --x 1
RESULT=commands_drift
Exit code 1, one machine-readable line per finding, with the document line, the command, the
unknown flag and the closest known flags — which is what --fix-report is for.
2.4 Coverage on the real repository
$ python check_commands.py
docs : 76 markdown file(s)
scripts : 39 argument table(s) read, 2 without a readable table
commands : 139 checked (139 clean, 0 drift, 0 warning)
skipped : 24 workbench, 1 external, 1 quoted, 0 unverifiable, 30 not a script invocation
RESULT=commands_ok
139 checked counts every invocation resolved to a readable interface, not merely the ones with
findings; a pass that reports only its findings cannot tell "clean" from "nothing looked at".
Everything skipped is printed with its reason, and the reasons are of four kinds:
- workbench (24): the script lives under
tools/, which.gitignoreexcludes, so a reader cannot open it and a missing file there is not drift. A bare filename that only exists undertools/is caught too, because those evidence records refer to workbench scripts by name. - not a script invocation (30):
python -m …,python -c "…", an elidedpython ... --flag, or an output line that merely starts withpython. - external (1):
python scripts\init_env_win.pyin the blutter build record is a path inside another repository's checkout — the record's preceding line isgit clone --depth 1 worawit/blutter. Each such entry carries its reason in an auditable table that the report prints, because a silent allow-list is indistinguishable from a missed drift. - quoted (1):
FINDINGS.mdquotespython scripts/sig_probe.py --live --pkg <pkg>as the defect it records. It is evidence, not an instruction; the page's own sentence says the script has no--pkg. Same auditable-table treatment.
The two scripts with no readable table are listed by name. A script with no flags has nothing that can drift, so its invocations pass without being checkable — stated so the edge of the check is visible instead of implied.
2.5 Defects the negative tests found in the checker itself
Each of these produced a false clean on the pre-fix page, and each was found by testing the checker against a known-bad document rather than by reading its code.
| # | Defect | How it surfaced | Fix |
|---|---|---|---|
| 1 | <pkg> was read as a shell input redirection, truncating the command at --pkg; the truncated command then checked clean |
the git-HEAD reproduction reported no drift at all | <...> with no interior space is treated as a placeholder token; >/>> still truncate |
| 2 | A flag that takes a value consumed the next token unconditionally, so --live --pkg made --pkg the value of --live |
the same reproduction, after fix 1 | an option-looking next token is not consumed, matching argparse's "expected one argument" |
| 3 | The whole documented line was read as one argv, so &&, the second python, and a redirect target entered the positional count |
3 warnings about check_repo.py && python check_refs.py and … --json > out.json |
lines are split on &&/||/;/| and truncated at a redirection |
| 4 | docs/** records reach workbench scripts as ../../tools/_work/…, which resolves to a real file, so they were reported as unverifiable repository scripts |
17 spurious entries | a resolved path under tools/ is classified workbench, and paths are realpath-normalised so dexpatch/../smtool.py is one entry, not two |
| 5 | commands_checked counted only commands with findings, so a clean run reported 3 checked instead of 139 |
comparing the summary against the extraction dump | every resolved invocation is emitted as a clean record and counted |
| 6 | --help fallback probes ran with cwd at the repository root |
reasoning about probe side effects, then confirmed | the probe runs in a temporary directory |
Defect 1 is the instructive one: a checker whose parser silently shortens a command reports success for exactly the inputs it was written to reject, and nothing in its own output looks wrong. The defence is not more careful reading, it is a fixture that is known to be bad.
2.6 A second blind spot, found by a reviewer reading this gate's output
A reviewer reported seven "drifting" commands. Reproducing them split the report three ways, and the middle category is the one worth reading.
Not reproducible. python tests/run_tests.py -q runs: run_tests.py forwards what it is given
to pytest (args = list(argv[1:]) or ["-q"]), and its usage block documents exactly that. dcc.py
is an external checkout, not a script this repository ships — the form the record uses is:
$ cd tools/_work/bench/repos/dcc
$ python ./dcc.py <SAMPLE_DIR>/baseline.apk --no-build --dynamic-register
Real, and fixed. Seven commands in two evidence records named a workbench script with no path
at all ($ python analyze_pair.py, b2_measure.py, ttd_attach.py, make_skeleton.py). Those files
exist under tools/, which .gitignore excludes, so a reader copying the command from the
repository root gets No such file. Two of those documents wrote the correct path elsewhere, so they
disagreed with themselves.
The part that matters: this gate had been scoring those ok. resolve_script() tried the
document's own directory first, so a bare analyze_pair.py inside docs/tool-verification/
resolved to tools/_work/bench/unpack/b3/analyze_pair.py — a real file — and passed. The fix is not
a stricter regex but a corrected resolution order plus a corrected classification:
- Resolution now follows the order a reader would try: repository root, then the skill's script
directory, then the document's own directory (which is what
../x.pyin a nested README means). - A bare name that only resolves under
tools/isunqualified-workbench, and it counts as drift, not a skip — a one-token fix that a reader cannot run. A first attempt classified it as a low-confidence warning, which would have left all seven in place. - The one honest exception is pinned too: a block that opens with
cd tools/_work/bench/repos/dcctells the reader where to stand, so a barepython dcc.pyafter it is correct. That is skipped asworkbench-after-cd.
2.6a The same gate failed on CI and passed locally, and the reason is worth keeping
The first version of the fix above shipped and CI went red on check_commands.py — in both the 3.11
and 3.13 jobs — while the identical command passed on the machine that wrote it. The difference is
tools/: it is gitignored, so a clean checkout does not have it, and recognition of a workbench
artifact by filename required the tree to exist. On the developer's box dcc.py matched a name
under tools/ and was skipped as workbench-after-cd; on CI nothing matched, it fell through to
missing-script, and the gate reported drift for three quoted lines in this very file.
Two lessons, both now enforced rather than remembered:
- A check whose verdict depends on a gitignored directory is not a check. The classification is
now computed from the document as well:
qualified_basenames()collects every.pythe text itself writes with a path, and a bare name that appears in that set is a shorthand the document already explains — so it skips identically on a clean checkout and on a populated one. - A gate that cannot be run in the same shape as CI will disagree with CI. Reproduce the CI
condition locally before believing a green run:
mv tools /tmp/hidden && python check_commands.pyis the whole experiment, and it takes seconds. That is what found this, after the fix had already been pushed.
Two defects of this pass's own, found while fixing it. A --json run printed the RESULT= token
after the JSON document, so --json | jq failed with Extra data; the token is gone from that path
and the status is a field in the document instead. And the fix's own substitution left four literal
backslashes before $ in the two files — a reader would have copied them.
tests/integration/test_check_commands_gate.py pins the resolution order, the drift classification,
the cd exception, and a clean pass over the committed tree, so this blind spot cannot return
silently.
2.7 What this check does not cover
- It validates flags and (weakly) positional counts, not semantics. A command with correct flags
and a wrong path still passes; so does a documented
--duration 14where the useful value is different. - A script whose interface is neither declarative nor printable is outside the check; the two current cases are listed by name rather than folded into the pass count.
- It does not verify that a command works — only that the script would not reject it as unknown arguments. Running the 139 commands remains a benchmark-pass activity.
3. APK rebuild safety in so_constpatch.py (teammate zip-safety; re-checked here)
3.1 What the committed writer did
# so_constpatch.py:204-218, before this round
with zipfile.ZipFile(out_path, 'w', zipfile.ZIP_DEFLATED) as dst:
for item in src.infolist():
blob = new_data if item.filename == entry else src.read(item.filename)
dst.writestr(zipfile.ZipInfo(item.filename, date_time=item.date_time),
blob, item.compress_type)
A new ZipInfo built from a filename and a timestamp carries nothing else, so extra and
external_attr are lost; create_system, internal_attr and the version fields are reset; and
every entry is re-compressed as DEFLATED, so a STORED resources.arsc may stop being STORED and
lose its 4-byte data alignment. There was no extractNativeLibs check, no alignment check, and no
pointer at scripts/repack.py.
3.2 What replaced it
The writer now emits the local headers and the central directory itself, because alignment is a
property of the local header length that zipfile cannot express. Per entry it reproduces the raw
local name bytes, both extra fields, external_attr, internal_attr, create_system,
create_version, extract_version, the comment, the DOS time/date words and the general-purpose
flags (the data-descriptor bit is cleared, since the size is known up front). An untouched STORED
entry's compressed stream is copied verbatim in 1 MiB chunks, so its CRC and compress_size are
identical by construction rather than by luck. Only the named entry is ever re-compressed, and only
if it was DEFLATED. Alignment padding goes into a private local extra record (0xCA) so the
original extra field stays a byte-for-byte prefix.
3.3 The offset that matters — a correction to state plainly
The alignment contract is the data offset of the entry, not its local-header offset. This matters because the two differ by the header length plus the name, and real archives routinely have an unaligned header offset:
r2pay (input, 435 entries): resources.arsc STORED data offset 0x3cec08 header_offset 0x3cebce
data offset % 4 == 0 OK header_offset % 4 == 2
zipalign, apksigner and repack.py's own check_alignment all test the data offset. So the
rule this tool enforces is: resources.arsc must be STORED and its data offset must be a
multiple of 4; any other STORED entry that arrived aligned must still be aligned after the
rebuild. The alignment predicate differs from repack.py's in one substantive way —
always : resources.arsc (Android R+ install gate)
always : STORED lib/**/*.so (extractNativeLibs=false maps them in place)
preserved: any other STORED entry already at a 4-byte data offset
never : DEFLATED entries, and STORED entries that arrived unaligned
— and it uses ^lib/.+\.so$ where repack.py uses ^lib/[^/]+\.so$. The latter matches
lib/x86/libfoo.so but not lib/arm64-v8a/libfoo.so, because the ABI directory is a second
path segment: on a real APK that pattern matches nothing, so the alignment filler it guards would
never fire. That is a live defect in repack.py (_needs_alignment, and the
check_alignment/zip_report paths that inherit the predicate), reported rather than fixed
because repack.py is in no one's write scope this round. It is worth fixing before the refusal
message tells users to switch to that tool.
3.4 Evidence, fixture level
arsc_aligned.apk (7 entries)
OLD writer vs INPUT NEW writer vs INPUT
extra differ 7/7 (nothing but offset)
eattr differ 7/7
csys differ 7/7
ver_made differ 7/7
untouched entries with changed CRC/size : 2 untouched entries with changed CRC/size : 0
resources.arsc: STORED @0x3a3 UNALIGNED resources.arsc: STORED @0x3e4 ALIGNED
zipalign -c -v 4 -> rc=1 zipalign -c -v 4 -> rc=0
3.5 Evidence, a real 435-entry package, and an independent re-check
Same sample, same patch, old writer versus new writer versus the input:
extra differ 205/435 (nothing but offset)
eattr differ 435/435
iattr, csys, ver_made differ 272/435
flags differ 164/435
untouched entries with changed CRC/size : 132 untouched entries with changed CRC/size : 0
file size 4,293,620 -> 3,897,949 file size 4,293,620 -> 4,289,340
resources.arsc: @0x36f931 UNALIGNED resources.arsc: @0x3cec08 ALIGNED
The old writer also shrinks the archive by ~396 KB: re-compressing entries that were already compressed changes the container, not the payload.
This pass re-checked the decisive claim with the Android build-tools' own verifier — a tool in neither teammate's write scope — on the artifacts the other teammate produced:
$ E:\tools\android-14\zipalign.exe -c -v 4 <file>
plain.old.apk rc=1 Verification FAILED plain.new.apk rc=0 Verification succesful
arsc.old.apk rc=1 Verification FAILED arsc.new.apk rc=0 Verification succesful
r2pay.old.apk rc=1 r2pay.new.apk rc=0
zipalign -c 4 checks every uncompressed entry, which is stricter than Android's install gate. The
bare-.so path (no container) was re-hashed as well and shows the intended zero regression:
0790faeaec0ed4ea8bd3e859db23840dd9d1c2635330b48c4d2e3a3f68fdd371 bare.old.so
0790faeaec0ed4ea8bd3e859db23840dd9d1c2635330b48c4d2e3a3f68fdd371 bare.new.so
3.6 The refusal gate
Rebuilding is declined by default when the package cannot be rebuilt safely, with the measurement and a pointer at the tool that owns a full resource-safe repack:
$ python skills/apk-reverse/scripts/so_constpatch.py tests/fixtures/apk/noextract.apk \
--entry lib/arm64-v8a/libfoo.so --replace apkhuan=android -o out.apk
extractNativeLibs=false
UNSAFE: AndroidManifest.xml declares android:extractNativeLibs="false": ...
REFUSED: this package cannot be rebuilt safely by this tool (see the UNSAFE line(s) above).
* If you want a resource-safe repack, use scripts/repack.py: ...
* ... re-run with --unsafe-rebuild to proceed anyway.
RESULT=refused_unsafe_rebuild exit=1 output written: no
Refusal is exit 1, not 3: 3 means the tool lacks the capability, whereas here the tool is
capable and the package is the reason nothing was written, so it is a negative finding about the
target. --unsafe-rebuild proceeds and still re-measures the STORED/alignment gate on its own
output. Exit codes follow the round's set — 0 success, 1 negative finding, 2 usage (including
an equal-length violation), 3 unsupported container (zip64, a non-deflate target, not a zip),
4 internal error with the output deleted — and --json carries
{status, exit_code, capability, evidence[], warnings[], next_action?} plus result, audit,
verify and patch, with the final line RESULT=<token>.
3.7 What section 3 does not establish
- No device, no install. No
adbinstall or cold start was run. The[-124]install failure is argued from the bytes and fromzipalign, not observed on a device.inferred. - No signing run. The rebuild output is unsigned by design;
repack.pyowns signing, so noapksigner verifyresult exists.unverified. - No genuine zip64 sample. zip64 is refused (
exit 3); the refusal path was exercised with a hand-built non-zip file, not with a >4 GiB archive. - Page alignment (4 KB/16 KB) is out of scope. This work guarantees 4-byte alignment, the
install gate. 4 KB/16 KB alignment for
extractNativeLibs=falsebuilds iszipalign -p's job and stays withrepack.py. --unsafe-rebuildcannot repair an already-compressedresources.arsc: the method is preserved, the self-check reports it as a warning, and the output stays non-compliant.
4. What this pass did not do
- No
pm installof any artifact, and therefore no cold-start or behavioural verification of a rebuilt or re-signed APK. Everything in §3 is container-level plus an external alignment verifier. - No signing. On this host
repack_signedis genuinely blocked (a JDK launcher shim, no build-tools onPATH, no signer jar), so the signed-repack path was not executed; the capability verdict is a probe result, not an observation of a failed run. - No smali round-trip, for the same reason: the eight jars are absent.
smali_roundtripis reported BLOCKED and was not exercised. dart_aot_fullwas not exercised: no snapshot dump exists in this session, and that capability reports an artifact the host cannot install rather than a missing package.check_commands.pywas not run against a benchmark target, only against this repository. Its false-clean rate on a foreign documentation set is unknown.- The 30 skipped invocations were not individually confirmed to be non-scripts; they are
classified by the reason printed for each (
-m,-c, an elision, or a prose line beginning withpython), and a misclassification there would be a missed check rather than a false finding. - One workbench nuisance observed and not cleaned up (not in any write scope): the tree carries
an untracked
--help/directory containingexempt/benign.txtandplanted/{appkey,device, endpoint,package,path}.txt, i.e. the planted leak-fixture layout. It is not produced byscan_leaks.py --help(reproduced in a scratch directory: usage only, nothing written) and not by this pass's tools, which now probe in a temporary directory. The generator istools/_work/leak-precedent/gen_fixtures.py, whose output directory is taken from an argument, so a probe that passed--helpas that argument would create exactly this. It is worth removing by whoever runs the final commit, because the directory holds deliberately leak-shaped files.
5. Files changed by this pass
New: skills/apk-reverse/scripts/capabilities.py, check_commands.py.
Rewritten: skills/apk-reverse/scripts/doctor.py (its documented CLI is unchanged; --scripts and
--json still work, and the JSON keeps its old keys).
Merged here from teammate zip-safety's work (their write scope, not this one):
skills/apk-reverse/scripts/so_constpatch.py, tests/test_so_constpatch.py,
tests/fixtures/make_fixture_apk.py, tests/fixtures/apk/{plain,arsc_aligned,noextract,services}.apk
and tests/fixtures/apk/.gitignore (the repo-wide *.apk ignore rule needs a !*.apk reverse rule
for fixtures to be stageable; git check-ignore -v confirms it).
Neither teammate committed or pushed. Nothing in §1 or §2 was measured on a device; §3's device work did not happen either.
6. Index lines handed to the Lead
skills/apk-reverse/SKILL.md(andREADME.mdwhere the tool table lists scripts):scripts/capabilities.py— new. "Capability registry: the single source of truth for what this host can actually do. Resolves a capability's realrequiresclosure (tools, modules, jar sets, device properties, input artifacts) intook | partial | blocked, with an executable next step and a cost estimate that names its own strength.--list,--json; used bydoctor.py."check_commands.py(repository root, sibling ofcheck_repo.py/check_refs.py) — new. "Validates every documented script invocation (python …) against the script's ownargparsetable, read withastso no script is executed to learn its interface.--fix-reportprintsdoc:line, the command, the unknown flag and the closest known flags. Exit 0 clean / 1 drift / 2 usage." It is a natural fourth entry in the publish-gate list wherever those are described.skills/apk-reverse/scripts/doctor.py— the existing row's wording can gain: "the script inventory is scanned from the directory (registered/checked = N / N, undecidable entries counted asunknown), and capabilities are probed rather than inferred fromjava".skills/apk-reverse/scripts/so_constpatch.py— the existing row can gain the clause fromzip-safety's §7: an APK rebuild preserves each entry's zip metadata (STORED stays STORED,extra/external_attr/create_systemintact,resources.arsckept STORED and 4-byte aligned) and is refused by default whenextractNativeLibs="false"orresources.arscis not STORED + aligned —scripts/repack.pyis the route for that package, or--unsafe-rebuildto override deliberately.repack.py:_needs_alignmentis a live defect (^lib/[^/]+\.so$cannot matchlib/<abi>/x.so), reported byzip-safetyand confirmed here by reading the predicate. Someone needs a write scope forrepack.py, or it should be recorded as a known gap.