mirror of
https://github.com/android/skills.git
synced 2026-09-14 20:07:09 +08:00
1314 lines
44 KiB
Python
Executable File
1314 lines
44 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
# Copyright 2026 The Android Open Source Project
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# https://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Coordinates the end-to-end Play Policy audit flow.
|
|
|
|
This manages everything from triage to analysis aggregation.
|
|
"""
|
|
|
|
import argparse
|
|
import concurrent.futures
|
|
import glob
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import uuid
|
|
import xml.etree.ElementTree as ET
|
|
|
|
import scanner
|
|
from template_engine import render_template
|
|
|
|
# Constants for Gradle parsing
|
|
GRADLE_APP_ID_PATTERN = (
|
|
r'applicationId\s*(?:[:=]|\.set\(?)\s*[\'"]?([a-zA-Z0-9._]+)[\'"]?\)?'
|
|
)
|
|
GRADLE_APP_ID_PROP_PATTERN = (
|
|
r'applicationId\s*[:=]?\s*project\.property\(\s*[\'"]([^\'"]+)[\'"]\s*\)'
|
|
)
|
|
GRADLE_NAMESPACE_PATTERN = (
|
|
r'namespace\s*(?:[:=]|\.set\(?)\s*[\'"]?([a-zA-Z0-9._]+)[\'"]?\)?'
|
|
)
|
|
GRADLE_NAMESPACE_PROP_PATTERN = (
|
|
r'namespace\s*[:=]?\s*project\.property\(\s*[\'"]([^\'"]+)[\'"]\s*\)'
|
|
)
|
|
GRADLE_TARGET_SDK_PATTERN = (
|
|
r"targetSdk(?:Version)?(?:\.set\(?)?\s*[:="
|
|
r" (]*\s*([\'\"]?[\w\d_\.]+[\'\"]?)\)?"
|
|
)
|
|
|
|
# Well-known framework defaults for unresolved dynamic variables (e.g. Flutter)
|
|
FRAMEWORK_DEFAULTS = {
|
|
"flutter.targetSdkVersion": 35,
|
|
"flutter.compileSdkVersion": 35,
|
|
"flutter.minSdkVersion": 21,
|
|
"flutter.ndkVersion": "25.1.8937393",
|
|
}
|
|
|
|
# .NET MAUI / .NET for Android express the target SDK through the target framework
|
|
# moniker rather than a manifest or Gradle file. An explicit platform level wins
|
|
# (net9.0-android35.0 -> 35); an unqualified moniker (net9.0-android) resolves to
|
|
# the highest API the installed workload supports, which is fixed per .NET release.
|
|
DOTNET_ANDROID_TARGET_SDK = {
|
|
"8.0": 34,
|
|
"9.0": 35,
|
|
"10.0": 36,
|
|
}
|
|
|
|
# net10.0-android, net10.0-android36.0, net8.0-android34.0 ...
|
|
DOTNET_TFM_PATTERN = re.compile(
|
|
r"net(\d+\.\d+)-android(\d+)?(?:\.\d+)?", re.IGNORECASE
|
|
)
|
|
|
|
# Patterns for diverse assignment styles (Groovy & Kotlin DSL)
|
|
# 1. Standard: targetSdkVersion = 35, ext.targetSdkVersion = 35, etc.
|
|
# 2. Delegated: val targetSdkVersion by extra(35)
|
|
# 3. Functional: set("targetSdkVersion", 35) or
|
|
# extra.set("targetSdkVersion", 35)
|
|
# 4. Indexer: extra["targetSdkVersion"] = 35
|
|
GRADLE_ASSIGNMENT_PATTERNS = [
|
|
re.compile(
|
|
r"(?:\b(?:val|var|def|const|static|internal|private|"
|
|
r"public|protected|ext|final)\s+)*"
|
|
r"([\w\d_\.]+)(?:\s*:\s*[\w\d_\.]+)?\s*=\s*['\"]?"
|
|
r"([^\x27\"\s\(\)<>\{\}]+)['\"]?"
|
|
),
|
|
re.compile(
|
|
r"val\s+([\w\d_\.]+)(?:\s*:\s*[\w\d_\.]+)?\s+by\s+"
|
|
r"extra\s*\(\s*['\"]?([^\x27\"\s\(\)]+)['\"]?\s*\)"
|
|
),
|
|
re.compile(
|
|
r"(?:extra\.)?set\s*\(\s*['\"]([\w\d_\.]+)['\"]\s*,\s*"
|
|
r"['\"]?([^\x27\"\s\(\)]+)['\"]?\s*\)"
|
|
),
|
|
re.compile(
|
|
r"extra\s*\[\s*['\"]([\w\d_\.]+)['\"]\s*\]\s*=\s*"
|
|
r"['\"]?([^\x27\"\s]+)['\"]?"
|
|
),
|
|
]
|
|
|
|
|
|
def _strip_inline_comment(line, support_quotes=True):
|
|
"""Strips inline comments starting with '#' while preserving quotes."""
|
|
in_double = False
|
|
in_single = False
|
|
for i, char in enumerate(line):
|
|
if support_quotes:
|
|
if char == '"' and not in_single:
|
|
in_double = not in_double
|
|
elif char == "'" and not in_double:
|
|
in_single = not in_single
|
|
|
|
if char == "#" and not in_double and not in_single:
|
|
return line[:i]
|
|
return line
|
|
|
|
|
|
def load_gradle_properties(target_dir):
|
|
"""Loads properties from gradle.properties and local.properties."""
|
|
properties = {}
|
|
for filename in ["gradle.properties", "local.properties"]:
|
|
path = os.path.join(target_dir, filename)
|
|
if os.path.exists(path):
|
|
try:
|
|
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
for line in f:
|
|
line = _strip_inline_comment(line, support_quotes=False).strip()
|
|
if line and not line.startswith("#") and "=" in line:
|
|
key, val = line.split("=", 1)
|
|
properties[key.strip()] = val.strip()
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
print(
|
|
f"Warning: Failed to load properties from {path}: {e}",
|
|
file=sys.stderr,
|
|
)
|
|
return properties
|
|
|
|
|
|
def load_version_catalog(target_dir):
|
|
"""Loads versions from gradle/libs.versions.toml if it exists."""
|
|
catalog = {}
|
|
toml_path = os.path.join(target_dir, "gradle", "libs.versions.toml")
|
|
if os.path.exists(toml_path):
|
|
try:
|
|
with open(toml_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
current_section = None
|
|
for line in f:
|
|
line = _strip_inline_comment(line).strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if line.startswith("[") and line.endswith("]"):
|
|
current_section = line[1:-1].strip()
|
|
continue
|
|
if current_section == "versions" and "=" in line:
|
|
key, val = line.split("=", 1)
|
|
val = val.strip().strip('"').strip("'")
|
|
catalog[key.strip()] = val.strip()
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
print(
|
|
f"Warning: Failed to load version catalog from {toml_path}: {e}",
|
|
file=sys.stderr,
|
|
)
|
|
return catalog
|
|
|
|
|
|
def load_custom_gradle_versions(target_dir, file_inventory=None):
|
|
"""Loads versions from shared Gradle files and project configuration."""
|
|
versions = {}
|
|
if not target_dir or not os.path.isdir(target_dir):
|
|
return versions
|
|
|
|
# 1. Identify Candidate Files (Root files are the primary targets)
|
|
candidate_files = []
|
|
|
|
# Leaking Strategy: If target_dir is a sub-module, leak up to find the root
|
|
# e.g. /path/to/project/app -> check /path/to/project for root build files
|
|
search_roots = [target_dir]
|
|
parent = os.path.dirname(os.path.normpath(target_dir))
|
|
if parent and parent != target_dir:
|
|
search_roots.append(parent)
|
|
|
|
for s_root in search_roots:
|
|
for f in ["build.gradle", "build.gradle.kts"]:
|
|
root_file = os.path.normpath(os.path.join(s_root, f))
|
|
if os.path.exists(root_file) and root_file not in candidate_files:
|
|
candidate_files.append(root_file)
|
|
|
|
# Use inventory if provided to avoid redundant file walk
|
|
if file_inventory:
|
|
search_lists = [
|
|
file_inventory.get("gradles", []),
|
|
file_inventory.get("source_code", []),
|
|
]
|
|
for file_list in search_lists:
|
|
for path in file_list:
|
|
norm_path = os.path.normpath(path)
|
|
if norm_path not in candidate_files:
|
|
file_lower = os.path.basename(norm_path).lower()
|
|
if any(
|
|
name in file_lower
|
|
for name in [
|
|
"versions.gradle",
|
|
"dependencies.gradle",
|
|
"variables.gradle",
|
|
]
|
|
):
|
|
candidate_files.append(norm_path)
|
|
elif any(
|
|
d in norm_path.lower() for d in ["build-logic", "buildsrc"]
|
|
) and file_lower.endswith((".gradle", ".gradle.kts", ".kt")):
|
|
candidate_files.append(norm_path)
|
|
else:
|
|
# Fallback to walk if no inventory provided
|
|
config = scanner.get_scanner_config()
|
|
ignored_dirs = set(config.get("ignored_directories", []))
|
|
for root, dirs, files in os.walk(target_dir):
|
|
dirs[:] = [d for d in dirs if d not in ignored_dirs]
|
|
for f in files:
|
|
if f.lower().endswith((".gradle", ".gradle.kts", ".kt")):
|
|
candidate_files.append(os.path.join(root, f))
|
|
|
|
# 2. Parse candidate files for assignments
|
|
for f_path in candidate_files:
|
|
try:
|
|
with open(f_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
content = f.read()
|
|
for pattern in GRADLE_ASSIGNMENT_PATTERNS:
|
|
for key, val in pattern.findall(content):
|
|
# Clean up the key (remove 'ext.' or 'project.' prefixes)
|
|
clean_key = key.split(".")[-1]
|
|
if clean_key not in versions:
|
|
versions[clean_key] = val.strip().strip('"').strip("'")
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
print(
|
|
f"Warning: Failed to parse Gradle file {f_path}: {e}", file=sys.stderr
|
|
)
|
|
|
|
return versions
|
|
|
|
|
|
def _load_json_file(path):
|
|
"""Safely loads a JSON file."""
|
|
if os.path.exists(path):
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
print(f"Warning: Failed to load JSON from {path}: {e}", file=sys.stderr)
|
|
return {}
|
|
|
|
|
|
def _read_text_file(path):
|
|
"""Safely reads a text file."""
|
|
if os.path.exists(path):
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
return f.read()
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
print(f"Warning: Failed to read file {path}: {e}", file=sys.stderr)
|
|
return ""
|
|
|
|
|
|
def _write_json_file(path, data):
|
|
"""Safely writes a JSON file."""
|
|
try:
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=4, sort_keys=True)
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
print(f"Warning: Failed to write JSON to {path}: {e}", file=sys.stderr)
|
|
|
|
|
|
def _write_text_file(path, content):
|
|
"""Safely writes a text file."""
|
|
try:
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
print(f"Warning: Failed to write file {path}: {e}", file=sys.stderr)
|
|
|
|
|
|
def _filter_by_flavor(data, detected_flavors, prioritized_flavors):
|
|
"""Recursively filters lists and dicts based on flavor path segments."""
|
|
if isinstance(data, dict):
|
|
filtered_dict = {}
|
|
for k, v in data.items():
|
|
filtered_val = _filter_by_flavor(v, detected_flavors, prioritized_flavors)
|
|
if isinstance(filtered_val, (dict, list)) and not filtered_val:
|
|
continue
|
|
filtered_dict[k] = filtered_val
|
|
return filtered_dict
|
|
|
|
elif isinstance(data, list):
|
|
filtered_list = []
|
|
for entry in data:
|
|
# Handle tuple entries (like in codebase_map.all_files)
|
|
entry_str = (
|
|
entry[1]
|
|
if isinstance(entry, tuple) and len(entry) > 1
|
|
else str(entry)
|
|
)
|
|
|
|
is_excluded = any(
|
|
f not in prioritized_flavors and f"src/{f}/" in entry_str
|
|
for f in detected_flavors
|
|
)
|
|
if not is_excluded:
|
|
filtered_list.append(entry)
|
|
return filtered_list
|
|
|
|
return data # Fallback for primitive types
|
|
|
|
|
|
def parse_application_modules(
|
|
gradle_files,
|
|
manifest_files,
|
|
gradle_properties,
|
|
version_catalog,
|
|
custom_gradle_versions=None,
|
|
):
|
|
"""Identifies distinct application modules in the project."""
|
|
modules = []
|
|
lookups = {
|
|
"properties": gradle_properties,
|
|
"catalog": version_catalog,
|
|
"custom": custom_gradle_versions or {},
|
|
}
|
|
|
|
# First, check Gradle files for the application plugin
|
|
for g_file in gradle_files:
|
|
try:
|
|
with open(g_file, "r", encoding="utf-8", errors="ignore") as f:
|
|
content = f.read()
|
|
if re.search(GRADLE_APP_PLUGIN_PATTERN, content):
|
|
# Found an app module!
|
|
mod_dir = os.path.dirname(g_file)
|
|
mod_name = os.path.basename(mod_dir)
|
|
|
|
# Try to extract details from this Gradle file
|
|
app_id = None
|
|
target_sdk = None
|
|
app_label = None
|
|
|
|
id_match = re.search(GRADLE_APP_ID_PATTERN, content)
|
|
if id_match:
|
|
app_id = id_match.group(1)
|
|
else:
|
|
prop_match = re.search(GRADLE_APP_ID_PROP_PATTERN, content)
|
|
if prop_match:
|
|
app_id = lookups["properties"].get(prop_match.group(1))
|
|
|
|
sdk_match = re.search(GRADLE_TARGET_SDK_PATTERN, content)
|
|
if sdk_match:
|
|
target_sdk = _resolve_to_int(sdk_match.group(1), lookups)
|
|
|
|
# Fallback to namespace if applicationId is missing
|
|
if not app_id:
|
|
ns_match = re.search(GRADLE_NAMESPACE_PATTERN, content)
|
|
if ns_match:
|
|
app_id = ns_match.group(1)
|
|
else:
|
|
ns_prop_match = re.search(GRADLE_NAMESPACE_PROP_PATTERN, content)
|
|
if ns_prop_match:
|
|
app_id = lookups["properties"].get(ns_prop_match.group(1))
|
|
|
|
modules.append({
|
|
"name": mod_name,
|
|
"path": mod_dir,
|
|
"application_id": app_id,
|
|
"target_sdk": target_sdk,
|
|
"app_label": app_label,
|
|
})
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
print(
|
|
f"Warning: Failed to parse Gradle file {g_file}: {e}", file=sys.stderr
|
|
)
|
|
|
|
return modules
|
|
|
|
|
|
def determine_primary_identity(
|
|
app_modules,
|
|
gradle_files,
|
|
manifest_files,
|
|
gradle_properties,
|
|
version_catalog,
|
|
custom_gradle_versions,
|
|
app_dir,
|
|
):
|
|
"""Heuristically determines the primary app ID and target SDK."""
|
|
primary_id = None
|
|
primary_sdk = None
|
|
primary_label = None
|
|
|
|
# 1. Prefer explicitly defined app modules
|
|
for mod in app_modules:
|
|
if mod.get("application_id"):
|
|
primary_id = mod["application_id"]
|
|
primary_sdk = mod["target_sdk"]
|
|
primary_label = mod["app_label"]
|
|
break
|
|
|
|
# 2. Fallback to global Gradle properties
|
|
if not primary_id:
|
|
primary_id = gradle_properties.get("applicationId") or (
|
|
gradle_properties.get("namespace")
|
|
)
|
|
if not primary_sdk:
|
|
primary_sdk = _resolve_to_int(
|
|
gradle_properties.get("targetSdkVersion")
|
|
or gradle_properties.get("targetSdk"),
|
|
{"properties": gradle_properties, "catalog": version_catalog},
|
|
)
|
|
|
|
# 3. Fallback to scanning manifests if still missing
|
|
if not primary_id or not primary_sdk:
|
|
for m_file in manifest_files:
|
|
try:
|
|
with open(m_file, "r", encoding="utf-8-sig") as f:
|
|
content = f.read()
|
|
details = extract_manifest_details(content)
|
|
if not primary_id:
|
|
primary_id = details.get("package_name")
|
|
if not primary_sdk:
|
|
primary_sdk = details.get("target_sdk")
|
|
if not primary_label:
|
|
primary_label = details.get("app_label")
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
pass
|
|
|
|
# 4. Cross-Platform Framework Detection
|
|
if not primary_id or not primary_sdk:
|
|
# Check for Flutter (pubspec.yaml)
|
|
pubspec_path = os.path.join(app_dir, "pubspec.yaml")
|
|
if os.path.exists(pubspec_path):
|
|
try:
|
|
with open(pubspec_path, "r", encoding="utf-8") as f:
|
|
for line in f:
|
|
if line.startswith("name:"):
|
|
primary_id = line.split(":")[1].strip()
|
|
break
|
|
primary_sdk = FRAMEWORK_DEFAULTS.get("flutter.targetSdkVersion")
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
pass
|
|
|
|
# Check for React Native (package.json)
|
|
pkg_json_path = os.path.join(app_dir, "package.json")
|
|
if os.path.exists(pkg_json_path):
|
|
try:
|
|
with open(pkg_json_path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
primary_id = data.get("name")
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
pass
|
|
|
|
# Check for .NET MAUI / .NET for Android (*.csproj)
|
|
if not primary_id or not primary_sdk:
|
|
dotnet_id, dotnet_sdk, dotnet_label = extract_dotnet_android_details(
|
|
app_dir
|
|
)
|
|
primary_id = primary_id or dotnet_id
|
|
primary_sdk = primary_sdk or dotnet_sdk
|
|
primary_label = primary_label or dotnet_label
|
|
|
|
return primary_id, primary_sdk, primary_label
|
|
|
|
|
|
def _msbuild_property(content, tag):
|
|
"""Reads a single MSBuild property value out of a project file."""
|
|
match = re.search(
|
|
rf"<{tag}[^>]*>([^<]+)</{tag}>", content, re.IGNORECASE
|
|
)
|
|
return match.group(1).strip() if match else None
|
|
|
|
|
|
def extract_dotnet_android_details(app_dir):
|
|
"""Finds the .NET MAUI / .NET for Android project and reads its identity.
|
|
|
|
A .NET for Android app has no Gradle files, and its AndroidManifest.xml carries
|
|
neither the package name nor uses-sdk -- both are MSBuild properties merged into
|
|
the manifest at build time. Without this the audit sees an app with no id and no
|
|
target SDK and reports nothing, which reads as a clean bill of health.
|
|
|
|
Args:
|
|
app_dir: Absolute path to the directory containing the app's code.
|
|
|
|
Returns:
|
|
A tuple of (application_id, target_sdk, application_title). Any element is
|
|
None when it could not be determined.
|
|
"""
|
|
app_id = None
|
|
target_sdk = None
|
|
label = None
|
|
|
|
config = scanner.get_scanner_config()
|
|
ignored_dirs = set(config.get("ignored_directories", []))
|
|
|
|
candidates = []
|
|
for root, dirs, files in os.walk(app_dir):
|
|
dirs[:] = [d for d in dirs if d not in ignored_dirs]
|
|
for f in files:
|
|
if f.lower().endswith(".csproj"):
|
|
candidates.append(os.path.join(root, f))
|
|
|
|
for csproj in sorted(candidates):
|
|
try:
|
|
with open(csproj, "r", encoding="utf-8", errors="ignore") as f:
|
|
content = f.read()
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
continue
|
|
|
|
content = re.sub(r"<!--.*?-->", "", content, flags=re.DOTALL)
|
|
|
|
tfm_match = DOTNET_TFM_PATTERN.search(content)
|
|
if not tfm_match:
|
|
# Not an Android head project (class libraries, test projects).
|
|
continue
|
|
|
|
app_id = app_id or _msbuild_property(content, "ApplicationId")
|
|
label = label or _msbuild_property(content, "ApplicationTitle")
|
|
|
|
if target_sdk is None:
|
|
# Explicit override wins, then the moniker's platform level, then the
|
|
# workload default for that .NET release.
|
|
explicit = _msbuild_property(content, "AndroidTargetSdkVersion")
|
|
if explicit and explicit.isdigit():
|
|
target_sdk = int(explicit)
|
|
elif tfm_match.group(2):
|
|
target_sdk = int(tfm_match.group(2))
|
|
else:
|
|
target_sdk = DOTNET_ANDROID_TARGET_SDK.get(tfm_match.group(1))
|
|
|
|
if app_id and target_sdk and label:
|
|
break
|
|
|
|
return app_id, target_sdk, label
|
|
|
|
|
|
def extract_manifest_details(xml_content):
|
|
"""Parses AndroidManifest.xml for key policy signals."""
|
|
details = {
|
|
"package_name": None,
|
|
"target_sdk": None,
|
|
"app_label": None,
|
|
"permissions": [],
|
|
"foreground_services": [],
|
|
}
|
|
|
|
try:
|
|
# Strip a UTF-8 BOM before parsing. Visual Studio writes one into
|
|
# AndroidManifest.xml by default, and ElementTree rejects it as an invalid
|
|
# token at line 1. The permission scan below is regex-based and survives, so
|
|
# the failure is silent: the manifest still yields permissions while quietly
|
|
# losing the package name, target SDK and app label.
|
|
root = ET.fromstring(xml_content.lstrip("\ufeff"))
|
|
ns = {"android": "http://schemas.android.com/apk/res/android"}
|
|
|
|
details["package_name"] = root.get("package")
|
|
|
|
# Target SDK
|
|
uses_sdk = root.find("uses-sdk", ns)
|
|
if uses_sdk is not None:
|
|
details["target_sdk"] = uses_sdk.get(
|
|
"{http://schemas.android.com/apk/res/android}targetSdkVersion"
|
|
)
|
|
|
|
# App Label
|
|
application = root.find("application", ns)
|
|
if application is not None:
|
|
label = application.get(
|
|
"{http://schemas.android.com/apk/res/android}label"
|
|
)
|
|
if label and label.startswith("@string/"):
|
|
details["app_label"] = label[8:]
|
|
else:
|
|
details["app_label"] = label
|
|
|
|
# Permissions
|
|
for tag in ["uses-permission", "uses-permission-sdk-23"]:
|
|
for perm in root.findall(tag, ns):
|
|
name = perm.get("{http://schemas.android.com/apk/res/android}name")
|
|
if name:
|
|
details["permissions"].append(name)
|
|
|
|
# Permissions from component declarations
|
|
for tag in [
|
|
"service",
|
|
"receiver",
|
|
"activity",
|
|
"provider",
|
|
"activity-alias",
|
|
]:
|
|
for component in root.findall(f".//{tag}", ns):
|
|
perm = component.get(
|
|
"{http://schemas.android.com/apk/res/android}permission"
|
|
)
|
|
if perm:
|
|
details["permissions"].append(perm)
|
|
|
|
if tag == "provider":
|
|
read_perm = component.get(
|
|
"{http://schemas.android.com/apk/res/android}readPermission"
|
|
)
|
|
if read_perm:
|
|
details["permissions"].append(read_perm)
|
|
write_perm = component.get(
|
|
"{http://schemas.android.com/apk/res/android}writePermission"
|
|
)
|
|
if write_perm:
|
|
details["permissions"].append(write_perm)
|
|
|
|
# Foreground Services
|
|
for service in root.findall(".//service", ns):
|
|
# Check for foregroundServiceType
|
|
fgs_type = service.get(
|
|
"{http://schemas.android.com/apk/res/android}foregroundServiceType"
|
|
)
|
|
if fgs_type:
|
|
details["foreground_services"].append({
|
|
"name": service.get(
|
|
"{http://schemas.android.com/apk/res/android}name"
|
|
),
|
|
"type": fgs_type,
|
|
})
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
print(f"Warning: Failed to parse XML: {e}", file=sys.stderr)
|
|
|
|
return details
|
|
|
|
|
|
def generate_triage_summary(manifest_details, data_safety_scan):
|
|
"""Generates a high-level summary of the audit landscape."""
|
|
summary = []
|
|
|
|
package = manifest_details.get("package_name") or "Unknown"
|
|
summary.append(f"Audit Target: {package}")
|
|
|
|
sdk = manifest_details.get("target_sdk") or "Unknown"
|
|
summary.append(f"Target SDK: {sdk}")
|
|
|
|
perms_count = len(manifest_details.get("permissions", []))
|
|
summary.append(f"Permissions Requested: {perms_count}")
|
|
|
|
signals_count = 0
|
|
if isinstance(data_safety_scan, dict):
|
|
inner_scan = data_safety_scan.get("data_safety_scan", {})
|
|
if "data_sources" in inner_scan:
|
|
signals_count = len(inner_scan["data_sources"])
|
|
summary.append(f"Sensitive Data Signals Found: {signals_count}")
|
|
|
|
return "\n".join(summary)
|
|
|
|
|
|
def run_scanner_direct(target_dir, file_inventory, output_file):
|
|
"""Invokes the scanner directly using the provided inventory."""
|
|
try:
|
|
# This calls the internal scan logic from scanner.py
|
|
results = scanner.perform_scan(target_dir, file_inventory)
|
|
_write_json_file(output_file, results)
|
|
return results
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
print(f"Error during scan: {e}", file=sys.stderr)
|
|
return {}
|
|
|
|
|
|
def write_agent_prompts(env_data, goal_map, output_data):
|
|
"""Generates surgical prompt files for worker agents and infers activation."""
|
|
repo_root = env_data.get("repo_root")
|
|
temp_dir = env_data.get("temp_dir")
|
|
app_dir = env_data.get("app_dir")
|
|
activated_goals = []
|
|
|
|
# 1. Load Shared Resources
|
|
policies_path = os.path.join(repo_root, "resources", "policies.json")
|
|
policies_data = _load_json_file(policies_path)
|
|
taxonomy = policies_data.get("data_safety_section", {}).get("taxonomy", {})
|
|
taxonomy_keys = set(taxonomy.keys())
|
|
|
|
common_mandates_path = os.path.join(
|
|
repo_root, "resources", "common_mandates.md"
|
|
)
|
|
common_mandates = _read_text_file(common_mandates_path)
|
|
|
|
config = scanner.get_scanner_config()
|
|
permission_groups = config.get("permission_groups", {})
|
|
|
|
# 2. Write Slices
|
|
if output_data:
|
|
for slice_name in ["manifest_details", "codebase_map"]:
|
|
slice_path = os.path.join(temp_dir, f"{slice_name}.json")
|
|
_write_json_file(slice_path, output_data.get(slice_name, {}))
|
|
|
|
# 3. Build Base Context
|
|
scan_results = output_data.get("data_safety_scan", {})
|
|
base_context = scan_results.get("data_safety_scan", {}).copy()
|
|
|
|
# Also ensure other metadata is included
|
|
base_context["found_urls"] = scan_results.get("found_urls", [])
|
|
base_context["semantic_files"] = scan_results.get("semantic_files", {})
|
|
|
|
# Flatten anonymous categories (those with only a "-" key)
|
|
for cat in [
|
|
"exact_alarm",
|
|
"accessibility",
|
|
"foreground_service",
|
|
"disclosure",
|
|
]:
|
|
if (
|
|
cat in base_context
|
|
and isinstance(base_context[cat], dict)
|
|
and "-" in base_context[cat]
|
|
):
|
|
base_context[cat] = base_context[cat]["-"]
|
|
|
|
manifest_details = output_data.get("manifest_details", {})
|
|
base_context["TARGET_SDK"] = manifest_details.get("target_sdk")
|
|
base_context["APP_NAME"] = output_data.get("app_name")
|
|
base_context["PACKAGE_NAME"] = output_data.get("package_name")
|
|
manifest_perms = manifest_details.get("permissions", [])
|
|
|
|
# Populate requested_permissions dictionary based on groups
|
|
requested_perms_dict = {}
|
|
for group_name, perms_to_check in permission_groups.items():
|
|
matched = [p for p in perms_to_check if p in manifest_perms]
|
|
if matched:
|
|
requested_perms_dict[group_name] = matched
|
|
|
|
base_context["requested_permissions"] = requested_perms_dict
|
|
base_context["TEMP_DIR"] = temp_dir
|
|
base_context["APP_DIR"] = app_dir
|
|
base_context["REPO_ROOT"] = repo_root
|
|
|
|
# 4. Smart Filtering for data_sources (Noise Reduction)
|
|
if "data_sources" in base_context:
|
|
filtered_sources = {}
|
|
for data_type, findings in base_context["data_sources"].items():
|
|
# Deduplicate by File (max 2 per unique file)
|
|
file_counts = {}
|
|
diverse_findings = []
|
|
for f in findings:
|
|
file_path = f.split(":L")[0] if ":L" in f else f
|
|
if file_counts.get(file_path, 0) < 2:
|
|
diverse_findings.append(f)
|
|
file_counts[file_path] = file_counts.get(file_path, 0) + 1
|
|
|
|
# Global Cap (max 3 per data type)
|
|
filtered_sources[data_type] = diverse_findings[:3]
|
|
base_context["data_sources"] = filtered_sources
|
|
|
|
max_evidence_per_worker = 6
|
|
|
|
# Helper to evaluate and save a goal instance
|
|
def _save_goal_instance(instance_name, context, template_content):
|
|
full_template = template_content + "\n\n" + common_mandates
|
|
final_prompt = render_template(full_template, context)
|
|
|
|
if "{{ACTIVATE_GOAL}}" in final_prompt:
|
|
final_prompt = final_prompt.replace("{{ACTIVATE_GOAL}}", "")
|
|
|
|
goal_input_file = os.path.join(
|
|
temp_dir, f"input_worker_{instance_name}.json"
|
|
)
|
|
_write_json_file(goal_input_file, context)
|
|
|
|
output_path = os.path.join(temp_dir, f"prompt_worker_{instance_name}.md")
|
|
_write_text_file(output_path, final_prompt)
|
|
return True
|
|
return False
|
|
|
|
# 5. Process Goals Sequentially (Removes Concurrency GIL Overhead & State
|
|
# Copies)
|
|
for goal_name, goal_conf in goal_map.items():
|
|
template_path = os.path.join(
|
|
repo_root, "resources", goal_conf["prompt_file"]
|
|
)
|
|
template_content = _read_text_file(template_path)
|
|
if not template_content:
|
|
continue
|
|
|
|
# Identify chunks for this goal
|
|
chunks = []
|
|
|
|
if goal_name == "data_safety" and "data_sources" in base_context:
|
|
all_evidence = []
|
|
for data_type, findings in base_context["data_sources"].items():
|
|
if not taxonomy_keys or data_type in taxonomy_keys:
|
|
for finding in findings:
|
|
all_evidence.append((data_type, finding))
|
|
|
|
for i in range(0, len(all_evidence), max_evidence_per_worker):
|
|
chunk = all_evidence[i : i + max_evidence_per_worker]
|
|
chunk_dict = {}
|
|
for dt, finding in chunk:
|
|
if dt not in chunk_dict:
|
|
chunk_dict[dt] = {
|
|
"description": taxonomy.get(dt, {}).get(
|
|
"description", "User data type."
|
|
),
|
|
"findings": [],
|
|
}
|
|
chunk_dict[dt]["findings"].append(finding)
|
|
chunks.append(chunk_dict)
|
|
|
|
if not chunks:
|
|
# Single pass for small goals
|
|
if _save_goal_instance(goal_name, base_context, template_content):
|
|
activated_goals.append(goal_name)
|
|
else:
|
|
# Multipass for large data safety sets
|
|
for idx, chunk_data in enumerate(chunks, 1):
|
|
instance_name = f"{goal_name}_part_{idx}"
|
|
chunk_context = base_context.copy()
|
|
chunk_context["data_sources"] = chunk_data
|
|
chunk_context["GOAL_NAME"] = instance_name
|
|
if _save_goal_instance(instance_name, chunk_context, template_content):
|
|
activated_goals.append(instance_name)
|
|
|
|
return activated_goals
|
|
|
|
|
|
def run_aggregation(temp_dir, repo_root):
|
|
"""Aggregates and chunks findings for the Critic."""
|
|
worker_pattern = os.path.join(temp_dir, "worker_*.json")
|
|
worker_files = sorted(glob.glob(worker_pattern))
|
|
|
|
all_findings = []
|
|
finding_id_counter = 1
|
|
|
|
for w_file in worker_files:
|
|
basename = os.path.basename(w_file)
|
|
try:
|
|
with open(w_file, "r", encoding="utf-8") as f:
|
|
w_data = json.load(f)
|
|
findings_list = w_data.get("findings", [])
|
|
if isinstance(findings_list, list):
|
|
for finding in findings_list:
|
|
# Store the source filename so generate_report can associate it
|
|
# correctly
|
|
finding["worker_file"] = basename
|
|
# Assign sequential ID
|
|
finding["finding_id"] = str(finding_id_counter)
|
|
finding_id_counter += 1
|
|
all_findings.append(finding)
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
print(
|
|
f"Warning: Failed to load worker file {w_file}: {e}", file=sys.stderr
|
|
)
|
|
|
|
# Save the master list of all findings
|
|
aggregated_path = os.path.join(temp_dir, "aggregated_findings.json")
|
|
with open(aggregated_path, "w", encoding="utf-8") as f:
|
|
json.dump({"findings": all_findings}, f, indent=4)
|
|
|
|
# Filter findings that need Critic evaluation: skip only SUGGESTION findings
|
|
critic_findings = []
|
|
for f in all_findings:
|
|
severity = str(f.get("severity", "SUGGESTION")).upper().strip()
|
|
if severity == "SUGGESTION":
|
|
continue
|
|
critic_findings.append(f)
|
|
|
|
# Chunk the findings for the Critic
|
|
chunk_size = 3
|
|
chunks = [
|
|
critic_findings[i : i + chunk_size]
|
|
for i in range(0, len(critic_findings), chunk_size)
|
|
]
|
|
|
|
# Render Critic template for each chunk
|
|
critic_template_path = os.path.join(repo_root, "resources", "critic.md")
|
|
critic_template = ""
|
|
if os.path.exists(critic_template_path):
|
|
with open(critic_template_path, "r", encoding="utf-8") as f:
|
|
critic_template = f.read()
|
|
|
|
common_mandates_path = os.path.join(
|
|
repo_root, "resources", "common_mandates.md"
|
|
)
|
|
common_mandates = ""
|
|
if os.path.exists(common_mandates_path):
|
|
with open(common_mandates_path, "r", encoding="utf-8") as f:
|
|
common_mandates = f.read()
|
|
|
|
full_template = critic_template + "\n\n" + common_mandates
|
|
|
|
# Load contextual values from manifest_details.json
|
|
manifest_path = os.path.join(temp_dir, "manifest_details.json")
|
|
base_context = {}
|
|
if os.path.exists(manifest_path):
|
|
try:
|
|
with open(manifest_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
m_details = json.load(f)
|
|
base_context["APP_DIR"] = m_details.get("app_dir")
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
pass
|
|
|
|
if not repo_root:
|
|
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
base_context["TEMP_DIR"] = temp_dir
|
|
base_context["REPO_ROOT"] = repo_root
|
|
|
|
for idx, chunk in enumerate(chunks, 1):
|
|
# Create input_critic_<idx>.json
|
|
chunk_dict = {f["finding_id"]: f for f in chunk}
|
|
chunk_input_path = os.path.join(temp_dir, f"input_critic_{idx}.json")
|
|
with open(chunk_input_path, "w", encoding="utf-8") as f:
|
|
json.dump(chunk_dict, f, indent=4)
|
|
|
|
# Render prompt_critic_<idx>.md
|
|
chunk_context = base_context.copy()
|
|
chunk_context["CHUNK_INDEX"] = str(idx)
|
|
|
|
final_prompt = render_template(full_template, chunk_context)
|
|
chunk_prompt_path = os.path.join(temp_dir, f"prompt_critic_{idx}.md")
|
|
with open(chunk_prompt_path, "w", encoding="utf-8") as f:
|
|
f.write(final_prompt)
|
|
|
|
return len(chunks)
|
|
|
|
|
|
GRADLE_APP_PLUGIN_PATTERN = (
|
|
r"(?:id\s*\(?\s*[\x27\x22]com\.android\.application[\x27\x22]\s*\)?|"
|
|
r"apply\s*\(?\s*(?:plugin:\s*)?[\x27\x22]com\.android\.application"
|
|
r"[\x27\x22]\s*\)?)(?!\s+apply\s+false)"
|
|
)
|
|
|
|
|
|
def _resolve_to_int(val, lookups, visited=None):
|
|
"""Recursively resolves a variable name to an integer value."""
|
|
if visited is None:
|
|
visited = set()
|
|
|
|
if not val:
|
|
return None
|
|
|
|
# Clean and normalize the value
|
|
val_str = str(val).strip().strip('"').strip("'")
|
|
|
|
# Support project.property("key") syntax
|
|
prop_match = re.search(
|
|
r'project\.property\s*\(\s*[\'"](.*?)[\'"]\s*\)', val_str
|
|
)
|
|
if prop_match:
|
|
val_str = prop_match.group(1).strip()
|
|
|
|
# Robust resolution for Gradle Version Catalogs and dynamic getters
|
|
# Strip common accessors and indirection prefixes
|
|
val_str = re.sub(
|
|
r"\.(?:get|toInt|getOrElse|provider)(?:\s*\([^)]*\))?", "", val_str
|
|
)
|
|
if val_str.startswith("libs.versions."):
|
|
val_str = val_str[14:]
|
|
elif val_str.startswith("libs."):
|
|
val_str = val_str[5:]
|
|
elif val_str.startswith("versions."):
|
|
val_str = val_str[9:]
|
|
|
|
if val_str.isdigit():
|
|
return int(val_str)
|
|
|
|
if val_str in visited:
|
|
return None # Circular reference protection
|
|
visited.add(val_str)
|
|
|
|
# 1. Direct and Namespaced Lookups
|
|
search_keys = [val_str]
|
|
if "." in val_str:
|
|
# If A.B.C fails, try B.C, then C
|
|
parts = val_str.split(".")
|
|
for i in range(1, len(parts)):
|
|
search_keys.append(".".join(parts[i:]))
|
|
|
|
for key in search_keys:
|
|
# Try custom versions first (usually has highest signal)
|
|
if key in lookups.get("custom", {}):
|
|
res = _resolve_to_int(lookups["custom"][key], lookups, visited)
|
|
if res is not None:
|
|
return res
|
|
|
|
# Try gradle properties
|
|
if key in lookups.get("properties", {}):
|
|
res = _resolve_to_int(lookups["properties"][key], lookups, visited)
|
|
if res is not None:
|
|
return res
|
|
|
|
# Try version catalog
|
|
if key in lookups.get("catalog", {}):
|
|
res = _resolve_to_int(lookups["catalog"][key], lookups, visited)
|
|
if res is not None:
|
|
return res
|
|
|
|
return None
|
|
|
|
|
|
def main():
|
|
"""Deterministic context-gathering tool for the Reviewer skill."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Deterministic context-gathering and state-query tool."
|
|
)
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
|
|
# Init subcommand
|
|
init_parser = subparsers.add_parser(
|
|
"init", help="Initialize scratch environment."
|
|
)
|
|
init_parser.add_argument("app_dir", help="Path to the Android application.")
|
|
|
|
# Aggregate subcommand
|
|
agg_parser = subparsers.add_parser("aggregate", help="Aggregate findings.")
|
|
agg_parser.add_argument(
|
|
"temp_dir", help="Path to the temporary scratch directory."
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
if args.command == "aggregate":
|
|
temp_dir = os.path.abspath(args.temp_dir)
|
|
if not os.path.isdir(temp_dir):
|
|
print(
|
|
f"Error: Specified temporary directory does not exist: {temp_dir}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
chunk_count = run_aggregation(temp_dir, repo_root)
|
|
print(json.dumps({"temp_dir": temp_dir, "critic_chunks": chunk_count}))
|
|
sys.exit(0)
|
|
|
|
# Initialization Mode
|
|
app_dir = os.path.abspath(args.app_dir)
|
|
if not os.path.isdir(app_dir):
|
|
print(f"Error: {app_dir} is not a valid directory.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
scratch_id = str(uuid.uuid4())
|
|
workspace_root = os.getcwd()
|
|
temp_dir = os.path.join(
|
|
workspace_root, ".scratch", f"play_policy_insights_{scratch_id}"
|
|
)
|
|
os.makedirs(temp_dir, exist_ok=True)
|
|
|
|
# Perform a single master filesystem walk using the scanner
|
|
file_inventory = scanner.collect_target_files(app_dir)
|
|
|
|
gradle_properties = load_gradle_properties(app_dir)
|
|
version_catalog = load_version_catalog(app_dir)
|
|
# Pass inventory to avoid redundant walk
|
|
custom_gradle_versions = load_custom_gradle_versions(app_dir, file_inventory)
|
|
|
|
codebase_map = {
|
|
"key_files": {
|
|
"manifests": file_inventory["manifests"],
|
|
"gradles": [
|
|
g
|
|
for g in file_inventory["gradles"]
|
|
if g.endswith((".gradle", ".gradle.kts"))
|
|
],
|
|
}
|
|
}
|
|
|
|
# 1. Detect and Filter Flavors (Play Store Prioritization)
|
|
detected_flavors = set()
|
|
src_dirs = []
|
|
|
|
config = scanner.get_scanner_config()
|
|
ignored_dirs = set(config.get("ignored_directories", []))
|
|
|
|
# Find all 'src' directories
|
|
for root, dirs, _ in os.walk(app_dir):
|
|
dirs[:] = [d for d in dirs if d not in ignored_dirs]
|
|
if "src" in dirs:
|
|
src_dirs.append(os.path.join(root, "src"))
|
|
dirs.remove("src") # Don't recurse deeper for 'src' search
|
|
|
|
for s_dir in src_dirs:
|
|
for flavor in os.listdir(s_dir):
|
|
if (
|
|
os.path.isdir(os.path.join(s_dir, flavor))
|
|
and flavor not in ignored_dirs
|
|
):
|
|
detected_flavors.add(flavor)
|
|
|
|
prioritized_flavors = []
|
|
if "play" in detected_flavors:
|
|
prioritized_flavors = ["main", "play"]
|
|
|
|
if prioritized_flavors:
|
|
# Filter codebase_map to only include prioritized flavors
|
|
print(
|
|
f"Prioritizing flavors: {', '.join(prioritized_flavors)}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
filtered_files = {}
|
|
for key, paths in codebase_map.get("key_files", {}).items():
|
|
filtered_paths = []
|
|
for p in paths:
|
|
# Check if path contains any non-prioritized flavor
|
|
is_excluded = False
|
|
for f in detected_flavors:
|
|
if f not in prioritized_flavors and f"src/{f}/" in p:
|
|
is_excluded = True
|
|
break
|
|
if not is_excluded:
|
|
filtered_paths.append(p)
|
|
filtered_files[key] = filtered_paths
|
|
codebase_map["key_files"] = filtered_files
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
|
# 1. Start the Data Safety Scanner immediately (independent task)
|
|
data_safety_scan_file = os.path.join(temp_dir, "data_safety_scan.json")
|
|
scanner_future = executor.submit(
|
|
run_scanner_direct, app_dir, file_inventory, data_safety_scan_file
|
|
)
|
|
|
|
# 2. Extract Application Modules and Primary Identity
|
|
app_modules = parse_application_modules(
|
|
codebase_map.get("key_files", {}).get("gradles", []),
|
|
codebase_map.get("key_files", {}).get("manifests", []),
|
|
gradle_properties,
|
|
version_catalog,
|
|
custom_gradle_versions=custom_gradle_versions,
|
|
)
|
|
primary_app_id, primary_target_sdk, primary_app_label = (
|
|
determine_primary_identity(
|
|
app_modules,
|
|
codebase_map.get("key_files", {}).get("gradles", []),
|
|
codebase_map.get("key_files", {}).get("manifests", []),
|
|
gradle_properties,
|
|
version_catalog,
|
|
custom_gradle_versions,
|
|
app_dir,
|
|
)
|
|
)
|
|
|
|
manifest_content = ""
|
|
manifest_details = {
|
|
"package_name": primary_app_id,
|
|
"app_label": primary_app_label,
|
|
"target_sdk": primary_target_sdk,
|
|
"app_dir": app_dir,
|
|
"permissions": [],
|
|
"foreground_services": [],
|
|
}
|
|
|
|
if codebase_map.get("key_files", {}).get("manifests"):
|
|
for _, manifest_path in enumerate(codebase_map["key_files"]["manifests"]):
|
|
# Filter Test-Bleed based on relative path
|
|
rel_path = os.path.relpath(manifest_path, app_dir)
|
|
normalized_rel_path = "/" + rel_path.replace("\\", "/")
|
|
if any(
|
|
x in normalized_rel_path
|
|
for x in [
|
|
"/debug/",
|
|
"/test/",
|
|
"/androidTest/",
|
|
"/testFixtures/",
|
|
]
|
|
):
|
|
continue
|
|
|
|
if os.path.exists(manifest_path):
|
|
try:
|
|
# Manifests are UTF-8. Without an explicit encoding this falls back
|
|
# to the platform default, which on Windows is the locale codepage:
|
|
# the file arrives mojibake and every downstream parse fails.
|
|
with open(manifest_path, "r", encoding="utf-8-sig") as f:
|
|
content = f.read()
|
|
if not manifest_content:
|
|
manifest_content = content
|
|
|
|
details = extract_manifest_details(content)
|
|
|
|
# Identity Fallbacks
|
|
if details.get("package_name") and not manifest_details.get(
|
|
"package_name"
|
|
):
|
|
manifest_details["package_name"] = details["package_name"]
|
|
|
|
if details.get("target_sdk") and not manifest_details.get(
|
|
"target_sdk"
|
|
):
|
|
manifest_details["target_sdk"] = details["target_sdk"]
|
|
|
|
if details.get("app_label") and not manifest_details.get(
|
|
"app_label"
|
|
):
|
|
manifest_details["app_label"] = details["app_label"]
|
|
|
|
manifest_details["permissions"].extend(
|
|
details.get("permissions", [])
|
|
)
|
|
manifest_details["foreground_services"].extend(
|
|
details.get("foreground_services", [])
|
|
)
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
print(f"Warning: Failed to read manifest: {e}", file=sys.stderr)
|
|
|
|
manifest_details["permissions"] = sorted(
|
|
list(set(manifest_details["permissions"]))
|
|
)
|
|
|
|
# Deduplicate and sort foreground services
|
|
unique_services = []
|
|
seen_services = set()
|
|
for svc in manifest_details["foreground_services"]:
|
|
svc_key = (svc.get("name"), svc.get("type"))
|
|
if svc_key not in seen_services:
|
|
seen_services.add(svc_key)
|
|
unique_services.append(svc)
|
|
|
|
# Final stable sort
|
|
manifest_details["foreground_services"] = sorted(
|
|
unique_services,
|
|
key=lambda x: (x.get("name") or "", x.get("type") or ""),
|
|
)
|
|
|
|
# 3. Gather results
|
|
data_safety_scan = scanner_future.result()
|
|
|
|
# 5. Post-Scan Flavor Filtering
|
|
if prioritized_flavors:
|
|
data_safety_scan = _filter_by_flavor(
|
|
data_safety_scan, detected_flavors, prioritized_flavors
|
|
)
|
|
|
|
# 6. Ingest local Play Store info
|
|
local_info_path = os.path.join(
|
|
app_dir, "play_store_assets", "play_store_info.json"
|
|
)
|
|
if os.path.exists(local_info_path):
|
|
try:
|
|
with open(local_info_path, "r", encoding="utf-8") as f:
|
|
play_store_info = json.load(f)
|
|
if "is_published" not in play_store_info:
|
|
play_store_info["is_published"] = True
|
|
if "store_url" not in play_store_info:
|
|
play_store_info["store_url"] = f"file://{local_info_path}"
|
|
|
|
with open(
|
|
os.path.join(temp_dir, "play_store_info.json"),
|
|
"w",
|
|
encoding="utf-8",
|
|
) as out_f:
|
|
json.dump(play_store_info, out_f, indent=4)
|
|
print(
|
|
f"Ingested local Play Store info from {local_info_path}",
|
|
file=sys.stderr,
|
|
)
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
print(
|
|
f"Warning: Failed to ingest local Play Store info: {e}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
reasoning = []
|
|
goal_map = {}
|
|
resources_dir = os.path.join(repo_root, "resources")
|
|
if os.path.exists(resources_dir):
|
|
for f in os.listdir(resources_dir):
|
|
if f.startswith("goal_") and f.endswith(".md"):
|
|
goal_name = f[5:-3]
|
|
goal_map[goal_name] = {"prompt_file": f}
|
|
|
|
if prioritized_flavors:
|
|
reasoning.append(
|
|
f"Prioritizing Play Store flavors: {', '.join(prioritized_flavors)}"
|
|
)
|
|
reasoning.append("Activated via template evaluation")
|
|
|
|
output_data = {
|
|
"app_name": (
|
|
manifest_details.get("app_label")
|
|
or os.path.basename(os.path.normpath(app_dir))
|
|
),
|
|
"package_name": manifest_details.get("package_name"),
|
|
"target_dir": app_dir,
|
|
"codebase_map": codebase_map,
|
|
"manifest_content": manifest_content,
|
|
"manifest_details": manifest_details,
|
|
"application_modules": app_modules,
|
|
"data_safety_scan": data_safety_scan,
|
|
"triage_summary": generate_triage_summary(
|
|
manifest_details, data_safety_scan
|
|
),
|
|
"triage_reasoning": "\n".join(reasoning),
|
|
"detected_flavors": sorted(list(detected_flavors)),
|
|
"prioritized_flavors": prioritized_flavors,
|
|
}
|
|
|
|
try:
|
|
activated_goals = write_agent_prompts(
|
|
{"repo_root": repo_root, "temp_dir": temp_dir, "app_dir": app_dir},
|
|
goal_map,
|
|
output_data,
|
|
)
|
|
print(f"Prompts generated in {temp_dir}", file=sys.stderr)
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
print(f"Warning: Failed to generate prompts: {e}", file=sys.stderr)
|
|
|
|
print(
|
|
json.dumps(
|
|
{"temp_dir": temp_dir, "activated_goals": activated_goals}, indent=4
|
|
)
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|