Files

676 lines
28 KiB
Python

#!/usr/bin/env python3
"""Environment doctor: what can actually run here, and what is missing.
Why this exists
---------------
This skill ships a lot of scripts, and the most common failure mode reported by
users is not "the script is wrong" but "the script would not start and I could not
tell why" - a missing jar, a Python module that does not exist on this interpreter,
a device that is not connected. That wastes a whole round before any reverse
engineering happens.
Run this once before a work block. It answers three questions:
1. Which capabilities are available right now (static / native / dynamic / device)?
2. For every script, is it runnable as-is, or what exactly is missing?
3. Are there environment facts that will silently poison experiments
(clock skew, a leftover proxy, a dead device server, a wrong-ABI device)?
It never installs anything and never touches a target. Read the report, then go.
What changed, and why the report got worse
------------------------------------------
This script used to answer question 1 from a hand-written table of 28 scripts (the
directory held 51) and from a formula that read
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`: signing needs a signer and `zipalign`, a smali
round-trip needs eight jars. On a host with only a JDK both reported OK, and every gate
downstream inherited an optimism nobody had measured. A capability verdict that is
wrong in the permissive direction is worse than no verdict at all, because it is used
to decide whether a claim is supportable.
So now:
* the script list is **scanned from this directory**, so it cannot go stale, and the
printed `registered/checked = N / N` counts every `.py` and `.js` file here;
* each script's third-party dependencies come from a **static AST pass** over its
imports, checked against `sys.stdlib_module_names` -- no script is imported or run
to find out what it needs;
* a script this pass cannot classify is printed as `unknown` and counted, never
dropped from the denominator;
* capability verdicts come from `capabilities.py`, the single registry, which probes
every atom of a capability's real closure (a missing input artifact included) and
prints an executable next step with a cost estimate.
A report that says BLOCKED on a host which genuinely cannot sign is the point. Fix the
named atom, or route the work through a capability that is ok.
Usage
-----
python doctor.py # capability + script summary
python doctor.py --scripts # per-script runnability table
python doctor.py --capabilities # every capability, with next actions
python doctor.py --device <serial> # include device checks
python doctor.py --json # machine-readable
Exit codes: 0 = nothing blocked, 3 = at least one capability blocked, 2 = usage error,
4 = internal error. The last line is `RESULT=<token>`.
"""
from __future__ import annotations
import argparse
import ast
import json
import os
import platform
import re
import subprocess
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
SKILL_DIR = os.path.dirname(HERE)
REPO_ROOT = os.path.dirname(os.path.dirname(SKILL_DIR))
if HERE not in sys.path:
sys.path.insert(0, HERE)
import capabilities as caps # noqa: E402 (path is set above on purpose)
EXIT_OK = 0
EXIT_USE = 2
EXIT_ENV = 3
EXIT_INTERNAL = 4
TOKENS = {
'ok': 'env_ok',
'partial': 'env_partial',
'blocked': 'env_blocked',
'use': 'usage_error',
'internal': 'internal_error',
}
_PROBE = caps.Probe()
# Host executables worth recognising inside a script's source. A literal outside this set
# is treated as data (an adb subcommand, a shell builtin, a path), never as a host tool:
# guessing too eagerly would report a missing tool that was never needed.
KNOWN_HOST_TOOLS = {
'java', 'javac', 'keytool', 'jarsigner', 'zipalign', 'apksigner', 'adb', 'aapt',
'aapt2', 'd8', 'dx', 'dexdump', 'frida', 'frida-ps', 'objection',
'node', 'npm', 'unzip', 'zip', '7z', 'git', 'sqlite3', 'tshark', 'tcpdump',
'mitmdump', 'rizin', 'rabin2', 'gdb', 'readelf', 'objdump', 'nm', 'strings',
'clang', 'ndk-build', 'cmake', 'make', 'python', 'python3', 'blutter', 'jadx',
'apktool', 'dex2jar', 'd2j-dex2jar',
}
# ---------------------------------------------------------------- probing
def extra_tool_dirs():
"""Directories to search beyond PATH (delegated to the capability registry)."""
return caps.extra_tool_dirs()
def which(name):
return _PROBE.which(name)
def run(cmd, timeout=15):
"""Run a command, returning (rc, stdout+stderr). Never raises, always bounded."""
try:
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=timeout)
return p.returncode, p.stdout.decode('utf-8', 'replace').strip()
except FileNotFoundError:
return 127, 'not found'
except subprocess.TimeoutExpired:
return 124, 'TIMEOUT after %ss' % timeout
except Exception as e: # pragma: no cover
return 1, '%s: %s' % (type(e).__name__, e)
def have_module(name):
"""Existence only. Importing a probe target can start threads and touch devices."""
return _PROBE.module(name)
# ---------------------------------------------------------------- tools
def tool_info():
"""name -> {path, version, note}. Version probes are bounded and failure is data."""
out = {}
probes = [
('java', ['java', '-version']),
('javac', ['javac', '-version']),
('keytool', ['keytool', '-help']),
('jarsigner', ['jarsigner', '-help']),
('adb', ['adb', 'version']),
('apksigner', ['apksigner', '--version']),
('zipalign', ['zipalign']),
('dexdump', ['dexdump']),
('frida', ['frida', '--version']),
('frida-ps', ['frida-ps', '--version']),
('objection', ['objection', '--version']),
('apktool', ['apktool', '--version']),
('jadx', ['jadx', '--version']),
('python', [sys.executable, '--version']),
('unzip', ['unzip', '-v']),
('git', ['git', '--version']),
('sqlite3', ['sqlite3', '--version']),
('node', ['node', '--version']),
('tshark', ['tshark', '-v']),
('mitmdump', ['mitmdump', '--version']),
('rizin', ['rizin', '-v']),
('rabin2', ['rabin2', '-v']),
('gdb', ['gdb', '--version']),
('readelf', ['readelf', '--version']),
('adb-devices', ['adb', 'devices']),
]
for name, cmd in probes:
if cmd[0] != sys.executable and which(cmd[0]) is None:
# A tool that ships as a runnable .jar is not on PATH but is still usable via
# `java -jar`. Reporting it missing would make the report lie downward.
jar_hit = _PROBE.find_jar(name)
if jar_hit:
out[name] = {'path': jar_hit,
'version': 'runnable as: java -jar %s'
% os.path.basename(jar_hit),
'note': 'jar-only (not a PATH command)'}
else:
out[name] = {'path': None, 'version': None, 'note': 'not on PATH'}
continue
rc, txt = run(cmd)
first = ''
for line in txt.splitlines():
line = line.strip()
if line:
first = line
break
out[name] = {
'path': which(cmd[0]) or cmd[0],
'version': first[:160],
'note': '' if rc == 0 else 'exit %d: %s' % (rc, txt[:120]),
}
return out
def java_toolchain():
"""Find the jars a smali round-trip or a signer needs, wherever they live."""
found = {}
for d in _PROBE.jar_dirs():
for dirpath, _dirs, files in os.walk(d):
if dirpath.count(os.sep) - d.count(os.sep) > 3:
continue
for f in files:
if f.lower().endswith('.jar'):
found.setdefault(f, os.path.join(dirpath, f))
return found
# ---------------------------------------------------------------- static script analysis
def _const_strings(tree):
"""Module- and function-level `NAME = 'literal'` bindings, best effort."""
consts = {}
for node in ast.walk(tree):
if isinstance(node, ast.Assign) and len(node.targets) == 1:
tgt, val = node.targets[0], node.value
if isinstance(tgt, ast.Name) and isinstance(val, ast.Constant) \
and isinstance(val.value, str):
consts.setdefault(tgt.id, val.value)
return consts
def _as_name(node, consts):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.Name):
return consts.get(node.id)
return None
def _first_list_elem(call_node, consts):
"""`subprocess.run(['adb', ...])`: the first element of the first list argument."""
for arg in getattr(call_node, 'args', []):
if isinstance(arg, ast.List) and arg.elts:
return _as_name(arg.elts[0], consts)
return None
def static_python_deps(path):
"""Third-party modules and host tools a .py file needs, from its source alone.
Returns (modules, tools, error). `error` is set when the file could not be parsed,
which the caller must surface as `unknown` rather than as "no dependencies".
"""
try:
with open(path, encoding='utf-8', errors='replace') as fh:
src = fh.read()
tree = ast.parse(src, path)
except (OSError, SyntaxError) as exc:
return [], [], '%s: %s' % (type(exc).__name__, exc)
stdlib = set(sys.stdlib_module_names)
local = {f[:-3] for f in os.listdir(HERE) if f.endswith('.py')}
modules, tools = set(), set()
consts = _const_strings(tree)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for a in node.names:
modules.add(a.name.split('.')[0])
elif isinstance(node, ast.ImportFrom):
if node.level == 0 and node.module:
modules.add(node.module.split('.')[0])
elif isinstance(node, ast.Call):
fn = node.func
attr = fn.attr if isinstance(fn, ast.Attribute) else (
fn.id if isinstance(fn, ast.Name) else '')
# A dependency is a tool the script *resolves in order to use*, or the
# program of a subprocess. A bare `shutil.which('x')` is only a probe -- the
# script usually handles the negative branch itself -- and counting it as a
# dependency reports doctor.py as needing frida-server, which is a device
# binary it merely looks for.
if attr in ('resolve_tool', 'find_executable'):
if node.args:
name = _as_name(node.args[0], consts)
if name in KNOWN_HOST_TOOLS:
tools.add(name)
elif attr in ('run', 'check_output', 'check_call', 'Popen', 'call', 'spawn',
'execvp', 'execv'):
name = _first_list_elem(node, consts)
if name in KNOWN_HOST_TOOLS:
tools.add(name)
third = sorted(m for m in modules
if m not in stdlib and m not in local and not m.startswith('_'))
return third, sorted(tools), None
def static_js_deps(path):
"""Modules a Frida JS script `require()`s. Frida's require is not a Python import,
so these are recorded as declarations, not resolved against the Python environment."""
try:
with open(path, encoding='utf-8', errors='replace') as fh:
src = fh.read()
except OSError as exc:
return [], 'OSError: %s' % exc
# strip line comments cheaply; a require() inside a string is not worth the parser
body = '\n'.join(l.split('//')[0] for l in src.splitlines())
return sorted(set(re.findall(r"""require\(\s*['"]([^'"]+)['"]\s*\)""", body))), None
def discover_scripts():
"""Every `.py` and `.js` in this directory. The list is the directory, not a table."""
names = []
for f in sorted(os.listdir(HERE)):
if f.endswith(('.py', '.js')) and os.path.isfile(os.path.join(HERE, f)):
names.append(f)
return names
def scan_scripts(cap_results):
"""Per-script facts: static deps, declared capability, and a verdict.
The verdict is the conjunction of the two independent facts about the script --
whether its own dependencies resolve, and whether the capability it serves is
available -- so a script is never reported runnable merely because it exists.
"""
rows = []
for name in discover_scripts():
path = os.path.join(HERE, name)
kind = 'js' if name.endswith('.js') else 'py'
cap_ids, cap_source = caps.capabilities_for_script(name, path)
row = {
'script': name, 'kind': kind, 'present': os.path.isfile(path),
'capabilities': cap_ids if cap_ids else [],
'capability_source': cap_source,
'modules': [], 'missing_modules': [], 'tools': [], 'missing_tools': [],
'js_requires': [], 'error': None, 'deps_source': '', 'verdict': 'ok',
}
if kind == 'py':
mods, tools, err = static_python_deps(path)
row['deps_source'] = 'ast-import-scan'
row['error'] = err
row['modules'] = mods
row['tools'] = tools
row['missing_modules'] = [m for m in mods if not have_module(m)]
row['missing_tools'] = [t for t in tools if which(t) is None]
else:
reqs, err = static_js_deps(path)
row['deps_source'] = 'js-require-scan'
row['error'] = err
row['js_requires'] = reqs
cap_states = [cap_results[c]['status'] for c in row['capabilities']
if c in cap_results]
missing_modules = row['missing_modules']
missing_tools = row['missing_tools']
cap_unknown = cap_ids is None
# A script's verdict is the conjunction of two independent facts: whether its own
# dependencies resolve, and whether the capability it serves is available.
#
# A missing *module* is fatal to the process (the import is at module level), so
# it blocks. A missing *tool* is not always fatal: repack.py resolves apksigner /
# keytool / zipalign only on the signing path, so on a host with no build-tools it
# is still fully usable with --no-sign, and calling that BLOCKED would be a
# pessimism of its own. When at least one capability the script serves is not
# blocked, a missing tool downgrades the script to PARTIAL and names the tool.
if cap_unknown or row['error']:
row['verdict'] = 'unknown'
elif cap_states and all(s == 'blocked' for s in cap_states):
row['verdict'] = 'blocked'
elif missing_modules:
row['verdict'] = 'blocked'
elif missing_tools and not cap_states:
row['verdict'] = 'blocked'
elif missing_tools or 'partial' in cap_states:
row['verdict'] = 'partial'
else:
row['verdict'] = 'ok'
row['runnable'] = row['verdict'] in ('ok', 'partial')
rows.append(row)
return rows
# ---------------------------------------------------------------- device
def device_report(serial=None):
if which('adb') is None:
return {'available': False, 'reason': 'adb not on PATH',
'devices': [], 'root': False, 'device_frida_processes': []}
cmd = ['adb'] + (['-s', serial] if serial else []) + ['devices', '-l']
rc, txt = run(cmd, timeout=20)
if rc != 0:
return {'available': False, 'reason': txt[:200], 'devices': [],
'root': False, 'device_frida_processes': []}
devices = []
for line in txt.splitlines()[1:]:
line = line.strip()
if not line or line.startswith('*'):
continue
parts = line.split()
if len(parts) >= 2:
devices.append({'serial': parts[0], 'state': parts[1],
'info': ' '.join(parts[2:])[:160]})
out = {'available': True, 'devices': devices, 'raw': txt[:400], 'root': False,
'device_frida_processes': []}
if not devices:
out['reason'] = 'no device/emulator attached'
return out
tgt = serial or devices[0]['serial']
props = {}
for key in ('ro.product.cpu.abi', 'ro.product.cpu.abilist', 'ro.build.version.release',
'ro.build.version.sdk', 'ro.product.model'):
rc, v = run(['adb', '-s', tgt, 'shell', 'getprop', key], timeout=15)
props[key] = v if rc == 0 else ''
out['target'] = tgt
out['props'] = props
# root?
rc, v = run(['adb', '-s', tgt, 'shell', 'su -c id'], timeout=15)
out['root'] = (rc == 0 and 'uid=0' in v)
out['root_raw'] = v[:120]
# clock skew: a classic silent experiment-poisoner
rc, dev_epoch = run(['adb', '-s', tgt, 'shell', 'date +%s'], timeout=15)
try:
dev_t = int(dev_epoch.strip())
out['clock_skew_s'] = abs(time.time() - dev_t)
except Exception:
out['clock_skew_s'] = None
# leftover forwards and proxies
rc, fwd = run(['adb', '-s', tgt, 'forward', '--list'], timeout=15)
out['forwards'] = [l for l in fwd.splitlines() if l.strip()][:10]
rc, prox = run(['adb', '-s', tgt, 'shell', 'settings get global http_proxy'], timeout=15)
out['http_proxy'] = prox.strip()[:120]
# is a frida server already running on device? (a common source of "the app
# suddenly detects instrumentation" while you believe nothing is attached)
rc, ps = run(['adb', '-s', tgt, 'shell', 'ps -A'], timeout=20)
hits = [l for l in ps.splitlines()
if 'frida' in l.lower() and 'grep' not in l.lower()]
out['device_frida_processes'] = [h.strip()[:140] for h in hits[:6]]
return out
# ---------------------------------------------------------------- reporting
def capability_section(results, verbose=True):
lines = caps.human_report(results, verbose=verbose)
blocked = [c for c, r in results.items() if r['status'] == 'blocked']
partial = [c for c, r in results.items() if r['status'] == 'partial']
lines.append('')
lines.append(' ok=%d partial=%d blocked=%d (of %d capabilities)'
% (len(results) - len(blocked) - len(partial), len(partial),
len(blocked), len(results)))
return lines
class _Parser(argparse.ArgumentParser):
"""argparse exits 2 on a usage error. Keep the RESULT= contract on that path too:
a caller that branches on the last line must not have to special-case bad usage."""
def error(self, message):
self.print_usage(sys.stderr)
sys.stderr.write('%s: error: %s\n' % (self.prog, message))
print('RESULT=%s' % TOKENS['use'])
raise SystemExit(EXIT_USE)
def main():
ap = _Parser(
description='Check what this environment can actually do. Exit 0 = nothing '
'blocked, 3 = a capability is blocked, 2 = usage, 4 = internal.')
ap.add_argument('--device', default=None, metavar='SERIAL', help='probe this device')
ap.add_argument('--scripts', action='store_true', help='per-script runnability table')
ap.add_argument('--capabilities', action='store_true',
help='every capability with its missing atoms and next actions '
'(the default prints the same list compactly)')
ap.add_argument('--json', action='store_true', dest='as_json', help='emit JSON')
args = ap.parse_args()
try:
py = {'version': '%d.%d.%d' % sys.version_info[:3],
'ok': sys.version_info[:2] >= (3, 9),
'note': '' if sys.version_info[:2] >= (3, 9)
else 'scripts target 3.9+; older interpreters may fail on syntax'}
dev = device_report(args.device)
# Hand the device facts to the registry so it does not re-probe the device.
_PROBE.set_device({
'available': bool(dev.get('devices')),
'devices': [d['serial'] for d in dev.get('devices', [])],
'root': bool(dev.get('root')),
'frida_server': bool(dev.get('device_frida_processes')),
'reason': dev.get('reason', ''),
})
cap_results = caps.resolve_all(_PROBE)
tools = tool_info()
jars = java_toolchain()
rows = scan_scripts(cap_results)
except Exception as exc: # pragma: no cover - defensive
sys.stderr.write('internal error: %s: %s\n' % (type(exc).__name__, exc))
print('RESULT=%s' % TOKENS['internal'])
return EXIT_INTERNAL
status, code, token = caps.verdict(cap_results)
counts = {'ok': 0, 'partial': 0, 'blocked': 0, 'unknown': 0}
for r in rows:
counts[r['verdict']] = counts.get(r['verdict'], 0) + 1
unknown = [r['script'] for r in rows if r['verdict'] == 'unknown']
total = len(rows)
py_files = sum(1 for r in rows if r['kind'] == 'py')
js_files = total - py_files
warnings = []
for cid, r in cap_results.items():
for m in r['partial']:
warnings.append('%s: reduced -- %s' % (cid, m['atom']))
if unknown:
warnings.append('dependency verdict unknown for %d script(s): %s'
% (len(unknown), ', '.join(unknown)))
for r in rows:
for m in r['missing_modules']:
warnings.append('%s: missing python module %s' % (r['script'], m))
for t in r['missing_tools']:
warnings.append('%s: missing tool %s' % (r['script'], t))
# The next action is the first *blocked* capability's, not the first partial one's:
# a reduced capability is a caveat, a blocked one is what stops the work.
hints = [cap_results[c]['next_action'] for c in caps.CAPABILITIES
if c in cap_results and cap_results[c]['status'] == 'blocked'
and cap_results[c]['next_action']]
if args.as_json:
payload = {
'status': status,
'exit_code': code,
'capability': None,
'next_action': hints[0] if hints else '',
'warnings': warnings,
'evidence': [
{'kind': 'capability', 'capability': cid, 'atom': e['atom'],
'ok': e['ok'], 'detail': e['detail'], 'source': e['source']}
for cid, r in cap_results.items() for e in r['evidence']
] + [
{'kind': 'script', 'script': r['script'],
'detail': 'verdict=%s deps=%s capabilities=%s'
% (r['verdict'], r['deps_source'],
','.join(r['capabilities']) or 'unknown')}
for r in rows
],
# the pre-existing keys are kept so an existing consumer keeps working
'python': py,
'tools': tools,
'jars': jars,
'device': dev,
'capabilities': cap_results,
'capabilities_legacy': {c: r['status'] for c, r in cap_results.items()},
'scripts': rows,
'script_counts': {'registered': total, 'checked': total,
'py': py_files, 'js': js_files,
'ok': counts['ok'], 'partial': counts['partial'],
'blocked': counts['blocked'], 'unknown': counts['unknown']},
}
print(json.dumps(payload, indent=2, ensure_ascii=False))
print('RESULT=%s' % token)
return code
print('=' * 74)
print('apk-reverse environment doctor')
print('=' * 74)
print('platform : %s %s / %s' % (platform.system(), platform.release(),
platform.machine()))
print('python : %s %s' % (py['version'], '' if py['ok'] else '<-- ' + py['note']))
print('\n--- capabilities (%d) ---' % len(cap_results))
for line in capability_section(cap_results, verbose=args.capabilities):
print(line)
print('\n--- tools ---')
for name, info in tools.items():
if name in ('adb-devices',):
continue
mark = 'OK ' if info['path'] else '-- '
ver = info['version'] or info['note']
print(' [%s] %-12s %s' % (mark, name, (ver or '')[:96]))
print('\n--- java jars found near this skill ---')
if jars:
for k in sorted(jars):
print(' %s' % k)
else:
print(' (none) - a smali round-trip needs the baksmali/smali/dexlib2 jar set.')
print(' smtool.py reads --cp, then APK_REVERSE_SMALI_CP, then scripts/smali_cp.txt.')
print(' doctor.py itself looks under APKREV_JARS and this skill directory.')
print('\n--- device ---')
if not dev.get('available'):
print(' unavailable: %s' % dev.get('reason'))
elif not dev.get('devices'):
print(' no device attached (adb works)')
else:
print(' target : %s' % dev.get('target'))
for k, v in (dev.get('props') or {}).items():
print(' %-9s: %s' % (k.replace('ro.', ''), v))
print(' root : %s' % ('yes' if dev.get('root') else 'no'))
skew = dev.get('clock_skew_s')
if skew is not None:
flag = ' <-- FIX THIS, clock drift poisons time-based checks' if skew > 30 else ''
print(' clock skew: %ss%s' % (skew, flag))
if dev.get('forwards'):
print(' leftovers : adb forward entries still present: %s' % dev['forwards'])
if dev.get('http_proxy', '').strip() not in ('', 'null', ':0'):
print(' leftovers : device http_proxy = %s' % dev['http_proxy'])
if dev.get('device_frida_processes'):
print(' WARNING : a frida process is already running on device:')
for p in dev['device_frida_processes']:
print(' %s' % p)
print(' If the target dies only while this is up, you are looking at')
print(' a probe aimed at YOU. Stop it before concluding anything.')
print('\n--- scripts ---')
print(' directory: %s' % HERE)
print(' registered/checked = %d / %d (.py=%d, .js=%d)'
% (total, total, py_files, js_files))
print(' ok=%d partial=%d blocked=%d unknown=%d'
% (counts['ok'], counts['partial'], counts['blocked'], counts['unknown']))
if args.scripts:
for r in rows:
mark = {'ok': 'OK ', 'partial': 'PARTIAL', 'blocked': 'BLOCKED',
'unknown': 'UNKNOWN'}[r['verdict']]
why = []
if not r['present']:
why.append('file missing')
if r['error']:
why.append('unparsed: %s' % r['error'][:60])
if r['missing_modules']:
why.append('missing modules: ' + ', '.join(r['missing_modules']))
if r['missing_tools']:
why.append('missing tools: ' + ', '.join(r['missing_tools']))
if r['kind'] == 'js':
why.append('frida JS (requires scanned: %s)'
% (', '.join(r['js_requires']) or 'none'))
if r['capability_source'] == 'mapped' and not r['capabilities']:
why.append('no capability mapped')
if r['capability_source'] == 'self':
why.append('reports on this environment rather than using it')
caps_txt = ','.join(r['capabilities']) if r['capabilities'] else (
'self' if r['capability_source'] == 'self' else 'unknown')
print(' [%s] %-30s %-28s %s'
% (mark, r['script'], caps_txt, '; '.join(why)))
if counts['unknown']:
print('\n UNKNOWN means this pass could not decide the dependency set. It is '
'counted above,\n not skipped: read the file before trusting it.')
else:
print(' use --scripts for the per-script table')
if counts['blocked']:
blocked_names = [r['script'] for r in rows if r['verdict'] == 'blocked']
print(' blocked scripts (%d): %s'
% (len(blocked_names), ', '.join(blocked_names[:8])
+ (' ...' if len(blocked_names) > 8 else '')))
if hints:
print('\n--- next action ---')
print(' %s' % hints[0])
print('\nRead references/long-task-discipline.md before a long block:')
print(' bound every wait, look at the screen while you wait, and record the '
'time-to-death before patching.')
print('RESULT=%s' % token)
return code
if __name__ == '__main__':
sys.exit(main())