# 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 ` — 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//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): ```python 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`) exposes `java` and `javac` but not `keytool` or `jarsigner`. 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|version` and `nargs=0` mean the flag consumes no value; - a script with no `argparse` at all (a hand-rolled `sys.argv` parser: `smtool.py`, `dex_strpatch.py`) falls back to parsing its own `--help` output, and `smtool.py`'s `--cp` is 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. ``-style placeholders are kept as single tokens, because reading `` 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 `--max` passes for `--max-depth`; an *ambiguous* prefix is reported. - `-h` / `--help` are 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 ` report `--pkg` rather 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 variable `nargs` or 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 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 ...: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 `.gitignore` excludes, so a reader cannot open it and a missing file there is not drift. A bare filename that only exists under `tools/` is caught too, because those evidence records refer to workbench scripts by name. - **not a script invocation** (30): `python -m …`, `python -c "…"`, an elided `python ... --flag`, or an output line that merely starts with `python`. - **external** (1): `python scripts\init_env_win.py` in the blutter build record is a path inside *another* repository's checkout — the record's preceding line is `git 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.md` quotes `python scripts/sig_probe.py --live --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 | `` 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 /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: 1. 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.py` in a nested README means). 2. A bare name that only resolves under `tools/` is `unqualified-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. 3. The one honest exception is pinned too: a block that opens with `cd tools/_work/bench/repos/dcc` tells the reader where to stand, so a bare `python dcc.py` after it is correct. That is skipped as `workbench-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 `.py` the 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.py` is 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 14` where 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 ```python # 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 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=`. ### 3.7 What section 3 does not establish - **No device, no install.** No `adb` install or cold start was run. The `[-124]` install failure is argued from the bytes and from `zipalign`, not observed on a device. `inferred`. - **No signing run.** The rebuild output is unsigned by design; `repack.py` owns signing, so no `apksigner verify` result 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=false` builds is `zipalign -p`'s job and stays with `repack.py`. - `--unsafe-rebuild` cannot repair an already-compressed `resources.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 install` of 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_signed` is genuinely blocked (a JDK launcher shim, no build-tools on `PATH`, 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_roundtrip` is reported BLOCKED and was not exercised. - **`dart_aot_full` was 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.py` was 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 with `python`), 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 containing `exempt/benign.txt` and `planted/{appkey,device, endpoint,package,path}.txt`, i.e. the planted leak-fixture layout. It is not produced by `scan_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 is `tools/_work/leak-precedent/gen_fixtures.py`, whose output directory is taken from an argument, so a probe that passed `--help` as 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` (and `README.md` where 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 real `requires` closure (tools, modules, jar sets, device properties, input artifacts) into `ok | partial | blocked`, with an executable next step and a cost estimate that names its own strength. `--list`, `--json`; used by `doctor.py`." - **`check_commands.py`** (repository root, sibling of `check_repo.py` / `check_refs.py`) — new. "Validates every documented script invocation (`python …`) against the script's own `argparse` table, read with `ast` so no script is executed to learn its interface. `--fix-report` prints `doc: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 as `unknown`), and capabilities are probed rather than inferred from `java`". - **`skills/apk-reverse/scripts/so_constpatch.py`** — the existing row can gain the clause from `zip-safety`'s §7: an APK rebuild preserves each entry's zip metadata (STORED stays STORED, `extra`/`external_attr`/`create_system` intact, `resources.arsc` kept STORED and 4-byte aligned) and is **refused by default** when `extractNativeLibs="false"` or `resources.arsc` is not STORED + aligned — `scripts/repack.py` is the route for that package, or `--unsafe-rebuild` to override deliberately. - **`repack.py:_needs_alignment` is a live defect** (`^lib/[^/]+\.so$` cannot match `lib//x.so`), reported by `zip-safety` and confirmed here by reading the predicate. Someone needs a write scope for `repack.py`, or it should be recorded as a known gap.