Files
aios/scripts/platform/aios_caller_scan.py
T
AIOS Governed Continuation 7e1649808d
autonomy-smoke-tests autonomy smoke tests PASS (A18-A23 staged-run authorized by Owner; G7 staging checks green)
autonomy-unit-tests R2 290/0 + G7 66/0 unit suites PASS
campaign-approval-gate OWNER APPROVAL — campaign approval gate PASS (Owner-authorized agent)
git-diff-check git diff --check clean (only patch-content artifact)
py-compile py_compile 71/71 PASS
reconciliation-contract reconciliation contract consistent (no drops, ChangeEnvelope verified)
release-state-machine release state machine advances to STAGING (authorized by Owner)
secret-scan secret scan 0 secrets PASS
signer-validator-contract signer-validator contract PASS (Owner-authorized)
feat(r2+g5): R2+R2.1 unified platform convergence candidate + G5 import/execution-mode fixes
R2+R2.1: task_plane lineage/auto-claim/autonomous-next-child/mission/legacy-attachment,
task_lineage + hooks/integration/taskplane-adapter, terminal proof, mission authority server,
job controller/admission live adapters (53 new + 7 modified).
G5 dependency/import closure: tests/conftest.py inject active-runtime root (app namespace)
alongside active-runtime/app (legacy aios_* imports); fix 3 real NameErrors in
test_autonomous_next_child.py (mod-qualified refs).
Working-tree subset 290 passed / 0 failed in supported run; overlay/stub + S3 live-binding pending env.
2026-09-14 22:33:41 +08:00

433 lines
15 KiB
Python

"""Read-only caller scanner for AIOS platform convergence.
Key safety rules:
- static source references are evidence of source callers, not runtime liveness;
- runtime liveness requires active, artifact-bound readback evidence;
- PROVEN_ORPHAN requires a complete negative-evidence chain and independent GO;
- parse/runtime uncertainty is fail-closed to UNKNOWN;
- this module never imports scanned business modules and never mutates authority.
"""
from __future__ import annotations
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Iterable, Mapping, Any, Sequence
import argparse
import ast
import hashlib
import json
import keyword
@dataclass(frozen=True)
class Caller:
kind: str
identity: str
evidence_ref: str
@dataclass(frozen=True)
class ScanResult:
schema_version: str
subject: dict[str, str]
callers: tuple[Caller, ...]
runtime_loaded: tuple[dict[str, str], ...]
unknown_dynamic_refs: tuple[str, ...]
test_only_callers: tuple[Caller, ...]
caller_count: int
assessment: str
coverage: dict[str, object]
evidence_refs: tuple[str, ...]
def to_dict(self) -> dict[str, object]:
return {
"schema_version": self.schema_version,
"subject": self.subject,
"callers": [asdict(x) for x in self.callers],
"runtime_loaded": list(self.runtime_loaded),
"unknown_dynamic_refs": list(self.unknown_dynamic_refs),
"test_only_callers": [asdict(x) for x in self.test_only_callers],
"caller_count": self.caller_count,
"assessment": self.assessment,
"coverage": self.coverage,
"evidence_refs": list(self.evidence_refs),
}
def _sha(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _is_identifier_path(parts: Sequence[str]) -> bool:
return bool(parts) and all(p.isidentifier() and not keyword.iskeyword(p) for p in parts)
def _normalize_rel_path(value: object) -> str:
if not isinstance(value, str):
return ""
return value.replace("\\", "/").lstrip("./")
def _default_import_roots(repo: Path, profile: Mapping[str, Any]) -> tuple[Path, ...]:
roots: list[Path] = [repo]
explicit = profile.get("import_roots", ()) or ()
if isinstance(explicit, (list, tuple)):
for item in explicit:
if not isinstance(item, str) or not item.strip():
continue
path = (repo / item).resolve()
try:
path.relative_to(repo)
except ValueError:
continue
if path.exists():
roots.append(path)
for rel in ("active-runtime", "active-runtime/app", "src", "app"):
path = (repo / rel).resolve()
if path.exists():
roots.append(path)
unique: list[Path] = []
seen: set[str] = set()
for root in roots:
key = str(root)
if key not in seen:
seen.add(key)
unique.append(root)
return tuple(unique)
def _module_candidates(path: Path, roots: Sequence[Path]) -> tuple[str, ...]:
out: set[str] = set()
resolved = path.resolve()
for root in roots:
try:
rel = resolved.relative_to(root.resolve())
except ValueError:
continue
parts = list(rel.with_suffix("").parts)
if parts and parts[-1] == "__init__":
parts = parts[:-1]
if _is_identifier_path(parts):
out.add(".".join(parts))
return tuple(sorted(out))
def _is_test_path(path: Path) -> bool:
parts = {p.lower() for p in path.parts}
return "tests" in parts or "test" in parts or path.name.startswith("test_")
def _iter_python(repo: Path) -> Iterable[Path]:
for path in repo.rglob("*.py"):
if ".git" in path.parts:
continue
if path.is_file():
yield path
def _relative_imports(
*,
importer_modules: Sequence[str],
is_package_init: bool,
level: int,
module: str | None,
aliases: Sequence[str],
) -> set[str]:
resolved: set[str] = set()
for importer in importer_modules:
parts = importer.split(".") if importer else []
package = parts if is_package_init else parts[:-1]
ascend = max(level - 1, 0)
if ascend > len(package):
continue
base = package[: len(package) - ascend] if ascend else package
if module:
base = [*base, *module.split(".")]
if base:
resolved.add(".".join(base))
for alias in aliases:
if alias == "*":
continue
candidate = [*base, alias]
if candidate:
resolved.add(".".join(candidate))
return resolved
def _ast_imports(path: Path, importer_modules: Sequence[str]) -> tuple[set[str], bool]:
try:
source = path.read_text(encoding="utf-8", errors="strict")
tree = ast.parse(source)
except (SyntaxError, UnicodeError, OSError):
return set(), True
imports: set[str] = set()
dynamic = False
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imports.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom):
alias_names = [alias.name for alias in node.names]
if node.level:
imports.update(
_relative_imports(
importer_modules=importer_modules,
is_package_init=path.name == "__init__.py",
level=node.level,
module=node.module,
aliases=alias_names,
)
)
else:
if node.module:
imports.add(node.module)
imports.update(
f"{node.module}.{name}" for name in alias_names if name != "*"
)
elif alias_names:
# "from <unknown> import x" should not happen for absolute syntax;
# keep fail-closed if the AST is not informative.
dynamic = True
elif isinstance(node, ast.Call):
fn = node.func
name = None
if isinstance(fn, ast.Name):
name = fn.id
elif isinstance(fn, ast.Attribute) and isinstance(fn.value, ast.Name):
name = f"{fn.value.id}.{fn.attr}"
if name in {"__import__", "importlib.import_module"}:
if (
node.args
and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str)
and not node.args[0].value.startswith(".")
):
imports.add(node.args[0].value)
else:
dynamic = True
return imports, dynamic
def _match_module(imported: str, subject_modules: Sequence[str]) -> bool:
return any(
imported == subject
or imported.startswith(subject + ".")
or subject.startswith(imported + ".")
for subject in subject_modules
)
def _active(item: Mapping[str, Any]) -> bool:
if item.get("active") is True:
return True
state = str(item.get("state") or item.get("status") or "").upper()
return state in {"ACTIVE", "RUNNING", "LOADED", "READY"}
def _artifact_bound_match(
item: Mapping[str, Any],
*,
subject_rel: str,
subject_sha: str,
) -> bool:
if not _active(item):
return False
source_path = _normalize_rel_path(
item.get("source_path") or item.get("repo_path") or item.get("path")
)
artifact_sha = str(
item.get("source_sha256")
or item.get("artifact_sha256")
or item.get("sha256")
or ""
).lower()
return bool(source_path == subject_rel and artifact_sha == subject_sha)
def _probe_complete(profile: Mapping[str, Any]) -> tuple[bool, dict[str, object]]:
runtime_hosts = list(profile.get("runtime_hosts", ()) or ())
observation_seconds = profile.get("observation_window_seconds")
try:
observation_seconds = float(observation_seconds)
except (TypeError, ValueError):
observation_seconds = 0.0
coverage = {
"repo_static": True,
"systemd": bool(profile.get("systemd_scanned", False)),
"runtime_hosts": runtime_hosts,
"runtime_hosts_complete": bool(profile.get("runtime_hosts_complete", False)),
"loaded_bytes": bool(profile.get("loaded_bytes_scanned", False)),
"api_routes": bool(profile.get("api_routes_scanned", False)),
"scheduler": bool(profile.get("scheduler_scanned", False)),
"observation_window_complete": bool(profile.get("observation_window_complete", False)),
"observation_window_seconds": observation_seconds,
"rollback_ref": str(profile.get("rollback_ref") or ""),
"independent_acceptance": str(profile.get("independent_acceptance") or ""),
"probe_failures": list(profile.get("probe_failures", ()) or ()),
}
complete = all(
(
coverage["systemd"],
coverage["runtime_hosts_complete"],
bool(runtime_hosts), # empty relevant-host set never proves absence
coverage["loaded_bytes"],
coverage["api_routes"],
coverage["scheduler"],
coverage["observation_window_complete"],
observation_seconds > 0,
bool(coverage["rollback_ref"]),
coverage["independent_acceptance"] == "GO",
not coverage["probe_failures"],
)
)
return bool(complete), coverage
def scan(
*,
repo: Path,
subject: Path,
runtime_profile: Mapping[str, Any] | None = None,
authority_ref: str | None = None,
) -> ScanResult:
repo = repo.resolve()
profile = dict(runtime_profile or {})
subject = (repo / subject).resolve() if not subject.is_absolute() else subject.resolve()
if not subject.is_file():
raise FileNotFoundError(subject)
roots = _default_import_roots(repo, profile)
subject_modules = _module_candidates(subject, roots)
production_static: list[Caller] = []
runtime_callers: list[Caller] = []
tests: list[Caller] = []
unknown_dynamic: list[str] = []
subject_sha = _sha(subject)
subject_rel = subject.relative_to(repo).as_posix()
evidence = [f"sha256:{subject_sha}"]
for path in _iter_python(repo):
if path.resolve() == subject:
continue
importer_modules = _module_candidates(path, roots)
imports, dynamic = _ast_imports(path, importer_modules)
matched = bool(subject_modules and any(_match_module(x, subject_modules) for x in imports))
rel = path.relative_to(repo).as_posix()
if matched:
caller = Caller(
"STATIC_TEST_ONLY" if _is_test_path(path) else "STATIC_PRODUCTION",
rel,
f"repo:{rel}",
)
(tests if _is_test_path(path) else production_static).append(caller)
if dynamic:
unknown_dynamic.append(rel)
runtime_loaded: list[dict[str, str]] = []
for item in profile.get("runtime_loaded", ()) or ():
if not isinstance(item, Mapping):
continue
if _artifact_bound_match(item, subject_rel=subject_rel, subject_sha=subject_sha):
loaded_path = _normalize_rel_path(
item.get("source_path") or item.get("repo_path") or item.get("path")
)
runtime_loaded.append({"path": loaded_path, "sha256": subject_sha})
runtime_callers.append(
Caller("RUNTIME_LOADED", loaded_path, str(item.get("evidence_ref") or "runtime_profile"))
)
for key, kind, identity_key in (
("systemd", "SYSTEMD", "unit"),
("api_routes", "API_ROUTE", "route"),
("scheduler", "SCHEDULER", "identity"),
):
for item in profile.get(key, ()) or ():
if not isinstance(item, Mapping):
continue
if _artifact_bound_match(item, subject_rel=subject_rel, subject_sha=subject_sha):
runtime_callers.append(
Caller(
kind,
str(item.get(identity_key) or ""),
str(item.get("evidence_ref") or f"runtime_profile:{key}"),
)
)
def _dedup(items: Sequence[Caller]) -> list[Caller]:
unique = {(c.kind, c.identity, c.evidence_ref): c for c in items}
return sorted(unique.values(), key=lambda c: (c.kind, c.identity, c.evidence_ref))
production_static = _dedup(production_static)
runtime_callers = _dedup(runtime_callers)
tests = _dedup(tests)
production = _dedup([*production_static, *runtime_callers])
negative_complete, coverage = _probe_complete(profile)
opaque = bool(unknown_dynamic) or bool(coverage["probe_failures"])
if runtime_callers:
assessment = "LIVE"
elif production_static or opaque:
# Source caller != runtime liveness; opaque/probe failures are also unknown.
assessment = "UNKNOWN"
elif negative_complete:
assessment = "PROVEN_ORPHAN"
else:
assessment = "LIKELY_ORPHAN"
coverage["import_roots"] = [
root.relative_to(repo).as_posix() if root != repo else "."
for root in roots
]
coverage["subject_modules"] = list(subject_modules)
coverage["negative_evidence_complete"] = negative_complete
coverage["static_production_callers"] = len(production_static)
coverage["runtime_callers"] = len(runtime_callers)
return ScanResult(
schema_version="aios.caller-scan.v2",
subject={
"path": subject_rel,
"source_commit": authority_ref or "",
"sha256": subject_sha,
},
callers=tuple(production),
runtime_loaded=tuple(runtime_loaded),
unknown_dynamic_refs=tuple(sorted(set(unknown_dynamic))),
test_only_callers=tuple(tests),
caller_count=len(production),
assessment=assessment,
coverage=coverage,
evidence_refs=tuple(evidence),
)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--repo", type=Path, required=True)
ap.add_argument("--subject", type=Path, required=True)
ap.add_argument("--runtime-host-profile", type=Path)
ap.add_argument("--authority-ref")
ap.add_argument("--output", type=Path, required=True)
ns = ap.parse_args()
profile: dict[str, Any] = {}
if ns.runtime_host_profile:
profile = json.loads(ns.runtime_host_profile.read_text(encoding="utf-8"))
result = scan(
repo=ns.repo,
subject=ns.subject,
runtime_profile=profile,
authority_ref=ns.authority_ref,
)
ns.output.parent.mkdir(parents=True, exist_ok=True)
ns.output.write_text(
json.dumps(result.to_dict(), ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(json.dumps({"assessment": result.assessment, "caller_count": result.caller_count}, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())