feat(lab): add isolated iPhone capture evidence

This commit is contained in:
DCCONSTRUCTIONS 2026-07-16 19:43:41 +03:00
parent 63dd8c8790
commit 19ab973110
11 changed files with 2428 additions and 0 deletions

View File

@ -0,0 +1,76 @@
# LixelGO / iPhone observation runbook
This runbook prepares the Mac without full Xcode and defines the first bounded,
read-only capture. The accepted decision is ADR 0005. Do not start the iPhone
experiment until the exact-profile physical regression in ADR 0004 passes.
## Current Mac baseline
- macOS 26.5.1, Apple Silicon;
- Xcode Command Line Tools 16.4 are present;
- full Xcode and `rvictl` are absent;
- built-in `tcpdump` and `uv` are present;
- Wireshark/tshark are not required for capture;
- the isolated lab uses Python 3.12 and `pymobiledevice3==9.36.0`.
## Preparation
```bash
uv sync --project plugins/xgrids-k1/lab/iphone-capture --frozen
uv run --project plugins/xgrids-k1/lab/iphone-capture --frozen \
pymobiledevice3 version
```
Expected version: `9.36.0`.
Connect one unlocked iPhone using a data cable, approve the Mac accessory prompt,
and tap **Trust This Computer**. Do not paste a UDID into Terminal or notes.
```bash
uv run --project plugins/xgrids-k1/lab/iphone-capture --frozen \
python plugins/xgrids-k1/lab/iphone-capture/preflight.py
```
## First smoke without K1
After the iPhone preflight succeeds, run a 10-second idle capture only after the
current source milestone is committed or with an explicitly recorded dirty-tree
fingerprint:
```bash
uv run --project plugins/xgrids-k1/lab/iphone-capture --frozen \
python plugins/xgrids-k1/lab/iphone-capture/capture.py \
--duration 10
```
The tool retains the original metadata-bearing `PCAPNG` and creates a compatible
classic `PCAP`. Validate the latter with built-in `tcpdump`:
```bash
tcpdump -nn -r sessions/iphone-k1-observation/<session>/captures/iphone-network.pcap -c 1
```
After the capture and operator timeline are both closed, seal the complete
session without rewriting the capture manifest:
```bash
uv run python plugins/xgrids-k1/lab/iphone-capture/session_integrity.py \
sessions/iphone-k1-observation/<session>
```
The derived private inventory verifies all manifest hashes and also hashes the
late operator-event artifact when it exists.
## Full baseline sequence
1. Complete the ADR 0004 physical K1 regression without LixelGO.
2. Close unrelated iPhone apps and pause unrelated network activity.
3. Keep the iPhone on the K1/shared Wi-Fi while USB remains connected to Mac.
4. Start a bounded 60180 second network capture.
5. Perform one predefined LixelGO action at a recorded monotonic offset.
6. Stop and retain the raw capture; analyze only offline.
7. Record endpoints, ports, sizes, timing and correlation before interpreting a
payload as a command, image, or model frame.
The first baseline does not install the Bluetooth logging profile. BLE/HCI is a
separate operator-approved experiment.

View File

@ -0,0 +1,76 @@
# ADR 0005: Owner-controlled LixelGO/iPhone observation
- Status: accepted
- Date: 2026-07-16
- Supersedes: only the unavailable-phone assumption in ADR 0001
## Context
ADR 0001 correctly described the original stand: no phone and no LixelGO were
available, so they could not be dependencies or test oracles. An
owner-controlled iPhone with LixelGO is now available for a new, evidence-only
experiment. The previous no-phone results remain valid historical evidence.
An ordinary third client on the mesh Wi-Fi cannot normally observe unicast
traffic between the iPhone and K1. Apple's native Remote Virtual Interface path
requires `rvictl`, which is installed by full Xcode and is absent from the
current Mac. Installing full Xcode solely for the first capture is unnecessary.
## Decision
The XGRIDS plugin may use a pinned, isolated `pymobiledevice3` lab environment
to read the iPhone `pcapd` service over a trusted USB connection. This mirrors
the iPhone IP stack while the phone remains connected to K1 over Wi-Fi. It is a
lab subprocess, not a root Mission Core dependency.
The experiment is evidence-only:
- no TLS interception or MITM certificate;
- no app automation;
- no new BLE or MQTT write;
- no router, firmware, device-file, or application mutation;
- no camera or command semantic claim until bounded decode and a controlled
physical correlation are both present.
Network capture does not include BLE HCI. Bluetooth capture is a separate gate
that requires Apple's diagnostic logging profile, explicit operator approval,
and removal of that profile after the experiment.
All captures are sensitive, mode `0600`, bounded in duration, stored under
ignored `sessions/`, hashed, and represented in Git/Ops only by redacted facts.
No device ID, credential, SSID, router metadata, or raw packet payload enters
normal Git history.
## Entry gate
LixelGO/iPhone observation starts only after ADR 0004's post-refactor physical
regression succeeds on exact firmware `3.0.2` and direct-LAN:
1. operator attestation;
2. prepare the receiver;
3. physical double-click start;
4. first real point frame and pose through the new normalizer/viewer path;
5. raw evidence finalization;
6. operator-confirmed physical stop;
7. verification that Mission Core emitted no K1 application command.
## Consequences
Full Xcode, `rvictl`, Wireshark, and system/Homebrew changes are not required for
the first IP capture. TLS or proprietary encryption may remain opaque; endpoint,
port, size, timing, interface, process, and plaintext local protocol evidence are
still useful. Any discovered state-changing contract requires a new profile
revision and its own acceptance gate before implementation.
## Outcome recorded on 2026-07-16
Lab 002 completed the bounded observation. It found a plaintext local MQTT data
and control plane plus two RTSP/H.264 camera preview endpoints. Owner-operated
LixelGO start and stop were correlated with bounded protobuf action values `1`
and `2`, device success responses, physical K1 transitions and start/stop of the
high-rate streams. No LixelGO WAN flow was observed in either principal window.
This outcome updates descriptive evidence only. Mission Core still emits no
modeling request: private header/settings construction, save-complete semantics,
timeouts and rollback remain unresolved. See
`docs/lab/002_LIXELGO_IPHONE_LOCAL_PROTOCOL_20260716.redacted.md`.

View File

@ -0,0 +1,180 @@
# Lab 002 — LixelGO/iPhone local K1 protocol observation
- Date: 2026-07-16
- Device scope: one owner-controlled LixelKity K1, exact firmware `3.0.2`
- Application scope: owner-operated LixelGO on a trusted iPhone
- Capture path: iPhone `pcapd` over USB to the Mac
- Result: **GO for protocol-backed camera discovery and start/stop mapping;
PAUSE for Mission Core command emission and save-state semantics**
This report contains no device identifier, address, SSID, credential, project
name, room image, or raw packet payload. The sensitive source captures remain
under ignored `sessions/` with mode `0600`.
## Experiment boundary
The operator performed one clean LixelGO lifecycle and one control/stop window:
1. K1 was powered to steady-green standby without a physical scan gesture.
2. LixelGO established Bluetooth control and requested the K1 Wi-Fi network.
3. The operator accepted the iOS Wi-Fi join request.
4. LixelGO entered its scan workspace; this navigation alone caused no K1 state
transition.
5. The operator supplied a private project name and confirmed start.
6. K1 performed static initialization and exposed point cloud, pose and camera
preview.
7. The operator selected left and right camera views.
8. The operator opened the stop confirmation and then confirmed it.
9. K1 stopped the LiDAR, displayed its saving state and returned to steady-green
standby without any physical K1 button press.
Bluetooth HCI was not captured. The BLE stage is supported by the operator's
screen/physical observation, while all protocol findings below start at the
iPhone IP stack.
## Evidence integrity
The two principal network sessions contain `108,188` and `207,123` packets.
Their raw PCAPNG and compatible classic PCAP hashes match the capture manifests,
and packet timestamps/order are equivalent across the two representations.
The original manifests and captures were left byte-for-byte unchanged. A
separate private integrity inventory re-hashes the manifests, packet files and
late operator-event JSONL. Operator events record sequence, not precise action
latency: several were narrated after the corresponding wire event.
## Network and privacy result
LixelGO accounted for more than `99.5%` of bytes in each principal capture and
communicated with exactly two private local endpoints. No LixelGO flow to a
public/WAN address was observed in either window. The first window contained a
very small amount of unrelated public traffic from other iPhone processes; the
second contained no public traffic at all.
This is bounded negative evidence for these sessions, not a permanent claim
about every application mode or future LixelGO release.
Observed K1 transports:
| Transport | Purpose | Result |
| --- | --- | --- |
| MQTT 3.1.1 over plaintext TCP `1883` | control, status, point cloud, pose | observed |
| RTSP 1.0 over TCP `8554` | left/right camera session control | observed |
| interleaved RTP/TCP, H.264 PT96, 90 kHz | camera preview media | observed |
| HTTP over TCP `80` | three empty project-related setup responses | ancillary only |
No LixelGO UDP flow was observed.
## Start and static initialization
The application start transaction is now physical wire evidence rather than
static-analysis evidence:
- QoS 2 PUBLISH topic: `lixel/application/request/modeling`;
- bounded protobuf top-level action field: `1`;
- correlated device response: about `1.5 ms` after the request, with the same
action and a success result;
- first pose: about `23.1 s` after the request;
- first modeling/point-cloud reports: about `24.1 s` after the request;
- first camera RTSP PLAY: about `25.1 s` after the request.
The observed 144-byte START protobuf has this bounded top-level wire shape
(private strings remain redacted):
```text
f1/wire2 = 130-byte private header/session string
f2/wire0 = 1
f3/wire2 = 3-byte private string
f4/wire0 = 2
f5/wire0 = 1
```
`f4` is the decoded value `2`; the occasionally noted `1` describes only the
one-byte varint encoding length and is not the field value. The successful
response repeats the private 130-byte field, reports `f2=1`, and includes an
observed success block in `f15/wire2` (15 bytes). These observations describe
the capture but do not establish safe construction rules for a new request.
The 2325 second interval matches the operator-observed static initialization.
The captured request contains additional private header/settings context. Its
complete construction and validation rules remain unresolved and are not
committed as a reusable command fixture.
## Live data plane
The existing repository decoder successfully decoded distributed samples of
both live spatial channels:
- point cloud: `lixel/application/report/lio_pcl`, about `9.4 Hz`, roughly
`2,5002,650` points per sampled frame;
- pose: `lixel/application/report/lio_pose`, about `10 Hz`, finite decoded
position/orientation fields.
During active scanning the aggregate K1-to-phone data plane averaged roughly
`1.11 MB/s`, split approximately into `0.25 MB/s` MQTT and `0.86 MB/s` camera
RTSP/RTP. The observed peak was about `1.89 MB/s`.
## Camera preview
The K1 exposes two independent path-labelled RTSP preview endpoints:
```text
rtsp://{confirmed-device-private-ipv4}:8554/live/chn_left_main
rtsp://{confirmed-device-private-ipv4}:8554/live/chn_right_main
```
Each session follows `OPTIONS`, `DESCRIBE`, `SETUP`, `PLAY`; media is H.264 in
interleaved RTP over TCP. The observed preview rate was approximately `10 fps`
and `6.456.59 Mbit/s`.
Camera selection is not an MQTT command. LixelGO tears down the current RTSP
session and opens the other endpoint. In the controlled left-to-right switch,
the new RTSP connection began about `0.8 s` after the preceding stream's last
packet. RTP arrived immediately after PLAY, but the first new SPS/PPS/IDR group
arrived about `0.79 s` later. This explains most of the operator-observed visual
delay; exact tap-to-display latency was not measurable from retrospective event
markers.
The capture proves a compressed preview path, not full-resolution raw camera
frames, camera calibration, stitched panorama geometry, or optical left/right
identity beyond the endpoint labels and operator-selected views.
## Stop and post-stop boundary
The remote stop transaction is also verified inside the packet capture:
- QoS 2 PUBLISH on the same modeling-request topic;
- bounded protobuf top-level action field: `2`;
- correlated success response after about `33 ms`;
- final point-cloud PUBLISH after about `36.4 ms`;
- final pose PUBLISH after about `40.8 ms`;
- final interleaved video packet after about `54.1 ms`;
- point-cloud UNSUBSCRIBE and RTSP TEARDOWN at about `0.95 s`;
- status/modeling reports for about another `9 s`;
- control MQTT and heartbeat remained reachable for about `31 s` without a
recurrence of high-rate point, pose, or video traffic.
The raw modeling-state value changed immediately after stop. Its enum name is
not decoded. The capture does not contain an independently verified
`save-complete`/`project-closed` response or the previously observed standby
state code. Completed saving and final steady-green standby are therefore
operator-corroborated, not packet-semantically proven.
## Decision
- **GO:** retain MQTT point cloud and pose as the current live spatial plane.
- **GO:** implement a read-only left/right RTSP/H.264 preview receiver behind
the XGRIDS plugin boundary.
- **GO:** promote start=`1` and stop=`2` from static hints to observed,
physical-correlated vendor mappings.
- **PAUSE:** keep all Mission Core vendor writes disabled until request headers,
settings, acknowledgements, timeout, save completion and rollback are modeled
and regression-tested.
- **PAUSE:** do not name the post-stop enum or claim durable project persistence
from this capture.
- **PAUSE:** BLE automation remains governed by the separately reviewed Wi-Fi
provisioning profile; no BLE/HCI inference is added here.
The next smallest implementation step is an offline/then-live read-only camera
adapter with bounded buffering, H.264 metadata, explicit left/right selection
and no acquisition command publisher.

View File

@ -0,0 +1 @@
3.12

View File

@ -0,0 +1,69 @@
# iPhone / LixelGO capture lab
This is an isolated evidence tool for the XGRIDS K1 plugin. It is not a Mission
Core runtime dependency and must not be bundled without a separate GPL license
review.
## Install without Xcode
From the repository root:
```bash
uv sync --project plugins/xgrids-k1/lab/iphone-capture --frozen
```
The environment is created under the lab directory. It pins
`pymobiledevice3==9.36.0`; it does not modify the system Python, Homebrew, the
Mission Core `.venv`, or the root lockfile.
## USB preflight
Connect exactly one iPhone with a data-capable USB cable, unlock it, confirm the
Mac accessory prompt, and tap **Trust This Computer** on the iPhone. Then run:
```bash
uv run --project plugins/xgrids-k1/lab/iphone-capture --frozen \
python plugins/xgrids-k1/lab/iphone-capture/preflight.py
```
The preflight prints only a device count. It never prints or stores the iPhone
UDID or serial number.
## Bounded network capture
Run this only after the post-refactor exact-profile K1 regression in ADR 0004:
```bash
uv run --project plugins/xgrids-k1/lab/iphone-capture --frozen \
python plugins/xgrids-k1/lab/iphone-capture/capture.py \
--duration 120
```
The capture uses the iPhone `pcapd` service over USB, suppresses packet hex on
stdout, and writes private artifacts under ignored
`sessions/iphone-k1-observation/`. It is intentionally bounded to 5600 seconds.
Each successful run retains the original metadata-bearing `PCAPNG` and creates a
classic Ethernet `PCAP` that the macOS bundled `tcpdump` can read. The redacted
manifest records the validated packet count and hashes both artifacts.
The default refuses a dirty repository; `--allow-dirty` records a source-tree
fingerprint when an explicitly approved lab run cannot wait for a commit.
Operator events may be recorded while the capture is active and can finish
after the bounded network process exits. Preserve the original capture manifest
and create a separate integrity inventory after the operator timeline is closed:
```bash
uv run python plugins/xgrids-k1/lab/iphone-capture/session_integrity.py \
sessions/iphone-k1-observation/<session>
```
The sealer re-hashes every manifest artifact, adds the manifest and optional
`operator-events.private.jsonl`, and writes an ignored mode-`0600`
`integrity.private.json`. It never edits the original manifest or raw capture.
Do not add `--bluetooth` unless Apple's Bluetooth diagnostic logging profile is
already installed on the iPhone and the operator explicitly approved that
configuration change. The script never installs the profile.
Wireshark is optional and is not required to record PCAP/PCAPNG. Full Xcode is
also not required for this path.

View File

@ -0,0 +1,313 @@
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import secrets
import struct
import subprocess
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from pcapng import FileScanner
from pcapng.blocks import EnhancedPacket
from preflight import (
pymobiledevice3_binary,
pymobiledevice3_version,
usb_device_count,
)
REPO_ROOT = Path(__file__).resolve().parents[4]
SESSION_ROOT = REPO_ROOT / "sessions" / "iphone-k1-observation"
SAFE_LABEL = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$")
def utc_now() -> str:
return datetime.now(UTC).isoformat()
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def git_output(*args: str) -> bytes:
completed = subprocess.run(
["git", *args],
cwd=REPO_ROOT,
check=True,
capture_output=True,
)
return completed.stdout
def source_snapshot() -> dict[str, Any]:
revision = git_output("rev-parse", "HEAD").decode().strip()
status = git_output("status", "--porcelain=v1", "--untracked-files=all")
dirty = bool(status.strip())
digest = hashlib.sha256()
digest.update(revision.encode())
digest.update(b"\0")
digest.update(status)
if dirty:
digest.update(git_output("diff", "--binary", "HEAD"))
untracked = git_output("ls-files", "--others", "--exclude-standard", "-z")
for raw_name in filter(None, untracked.split(b"\0")):
relative = raw_name.decode("utf-8", errors="surrogateescape")
path = REPO_ROOT / relative
digest.update(raw_name)
digest.update(b"\0")
if path.is_file():
digest.update(sha256_file(path).encode())
return {
"git_revision": revision,
"source_tree_dirty": dirty,
"source_tree_fingerprint_sha256": digest.hexdigest(),
}
def terminate(process: subprocess.Popen[bytes]) -> int:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
return int(process.returncode or 0)
def artifact_record(path: Path, kind: str, session_dir: Path) -> dict[str, Any] | None:
if not path.is_file() or path.stat().st_size == 0:
return None
path.chmod(0o600)
return {
"kind": kind,
"relative_path": str(path.relative_to(session_dir)),
"size_bytes": path.stat().st_size,
"sha256": sha256_file(path),
}
def convert_pcapng_to_pcap(source: Path, destination: Path) -> int:
"""Create a classic Ethernet PCAP that macOS's bundled tcpdump can read."""
packet_count = 0
with source.open("rb") as source_stream, destination.open("xb") as destination_stream:
destination_stream.write(
struct.pack(
"<IHHIIII",
0xA1B2C3D4,
2,
4,
0,
0,
262_144,
1,
)
)
for block in FileScanner(source_stream):
if not isinstance(block, EnhancedPacket):
continue
packet_data = bytes(block.packet_data)
captured_length = int(block.captured_len)
packet_length = int(block.packet_len)
if captured_length != len(packet_data) or packet_length < captured_length:
raise ValueError("invalid enhanced-packet lengths")
timestamp = float(block.timestamp)
seconds = int(timestamp)
microseconds = int(round((timestamp - seconds) * 1_000_000))
if microseconds >= 1_000_000:
seconds += 1
microseconds -= 1_000_000
destination_stream.write(
struct.pack(
"<IIII",
seconds,
microseconds,
captured_length,
packet_length,
)
)
destination_stream.write(packet_data)
packet_count += 1
destination.chmod(0o600)
return packet_count
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Bounded, redacted USB iPhone capture for the Mission Core K1 lab"
)
parser.add_argument("--duration", type=int, default=120, help="capture seconds (5..600)")
parser.add_argument("--label", default="lixelgo-observe", help="safe session label")
parser.add_argument("--process", help="optional iOS process filter for network capture")
parser.add_argument("--interface", help="optional iOS interface filter for network capture")
parser.add_argument(
"--bluetooth",
action="store_true",
help="also capture iPhone Bluetooth HCI; requires Apple's logging profile",
)
parser.add_argument(
"--acknowledge-ios-bluetooth-profile",
action="store_true",
help="confirm the operator already installed Apple's Bluetooth logging profile",
)
parser.add_argument(
"--allow-dirty",
action="store_true",
help="record an explicit dirty-tree fingerprint instead of refusing the capture",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
if not 5 <= args.duration <= 600:
raise SystemExit("--duration must be between 5 and 600 seconds")
if not SAFE_LABEL.fullmatch(args.label):
raise SystemExit("--label must use lowercase ASCII letters, digits, and hyphens")
if args.bluetooth and not args.acknowledge_ios_bluetooth_profile:
raise SystemExit(
"Bluetooth capture requires --acknowledge-ios-bluetooth-profile; "
"the script never installs the profile"
)
os.umask(0o077)
binary = pymobiledevice3_binary()
if usb_device_count(binary) != 1:
raise SystemExit("exactly one trusted USB iPhone must be connected")
snapshot = source_snapshot()
if snapshot["source_tree_dirty"] and not args.allow_dirty:
raise SystemExit(
"repository is dirty; commit the milestone or pass --allow-dirty "
"to record its fingerprint"
)
session_id = (
datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + f"_{args.label}_{secrets.token_hex(2)}"
)
session_dir = SESSION_ROOT / session_id
capture_dir = session_dir / "captures"
capture_dir.mkdir(parents=True, mode=0o700)
network_pcapng_path = capture_dir / "iphone-network.pcapng"
network_pcap_path = capture_dir / "iphone-network.pcap"
bluetooth_path = capture_dir / "iphone-bluetooth.pcapng"
stderr_path = session_dir / "capture.stderr.log"
manifest_path = session_dir / "manifest.redacted.json"
manifest: dict[str, Any] = {
"schema": "missioncore.xgrids-k1/iphone-observation-manifest/v1alpha1",
"session_id": session_id,
"status": "starting",
"started_at_utc": utc_now(),
"started_monotonic_ns": time.monotonic_ns(),
"requested_duration_seconds": args.duration,
"capture_backend": "pymobiledevice3-9.36.0/com.apple.pcapd",
"pymobiledevice3_version": pymobiledevice3_version(binary),
"network_capture": True,
"bluetooth_capture": args.bluetooth,
"network_process_filter": args.process,
"network_interface_filter": args.interface,
"device_identity_recorded": False,
"credentials_recorded_in_manifest": False,
"source": snapshot,
"artifacts": [],
}
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
manifest_path.chmod(0o600)
network_command = [binary, "--no-color", "pcap", "--out", str(network_pcapng_path)]
if args.process:
network_command.extend(["--process", args.process])
if args.interface:
network_command.extend(["--interface", args.interface])
commands = [("network", network_command)]
if args.bluetooth:
commands.append(
(
"bluetooth",
[binary, "--no-color", "btlogger", str(bluetooth_path), "--format", "pcapng"],
)
)
processes: list[tuple[str, subprocess.Popen[bytes]]] = []
exit_codes: dict[str, int] = {}
interrupted = False
failure: str | None = None
with stderr_path.open("wb") as error_log:
stderr_path.chmod(0o600)
try:
for name, command in commands:
process = subprocess.Popen(
command,
cwd=REPO_ROOT,
stdout=subprocess.DEVNULL,
stderr=error_log,
)
processes.append((name, process))
time.sleep(1)
early = [name for name, process in processes if process.poll() is not None]
if early:
raise RuntimeError(f"capture service exited early: {', '.join(early)}")
deadline = time.monotonic() + args.duration
while time.monotonic() < deadline:
ended = [name for name, process in processes if process.poll() is not None]
if ended:
raise RuntimeError(f"capture service stopped unexpectedly: {', '.join(ended)}")
time.sleep(min(0.25, deadline - time.monotonic()))
except KeyboardInterrupt:
interrupted = True
except RuntimeError as exc:
failure = str(exc)
finally:
for name, process in processes:
exit_codes[name] = terminate(process)
network_packet_count: int | None = None
if failure is None:
try:
network_packet_count = convert_pcapng_to_pcap(
network_pcapng_path,
network_pcap_path,
)
if network_packet_count == 0:
failure = "network capture validation found no packets"
except Exception as exc: # noqa: BLE001 - third-party parser errors vary by version
failure = f"network capture validation failed: {type(exc).__name__}"
artifacts = [
artifact_record(network_pcapng_path, "iphone-network-pcapng-raw", session_dir),
artifact_record(network_pcap_path, "iphone-network-pcap-compatible", session_dir),
artifact_record(bluetooth_path, "iphone-bluetooth-hci-pcapng", session_dir),
artifact_record(stderr_path, "capture-stderr", session_dir),
]
manifest.update(
{
"status": "failed" if failure else "completed",
"interrupted_by_operator": interrupted,
"failure": failure,
"ended_at_utc": utc_now(),
"ended_monotonic_ns": time.monotonic_ns(),
"process_exit_codes": exit_codes,
"network_packet_count": network_packet_count,
"artifacts": [item for item in artifacts if item is not None],
}
)
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
manifest_path.chmod(0o600)
print(f"session: {session_dir}")
print(f"status: {manifest['status']}")
return 1 if failure else 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,76 @@
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from pathlib import Path
def pymobiledevice3_binary() -> str:
sibling = Path(sys.executable).with_name("pymobiledevice3")
if sibling.is_file():
return str(sibling)
discovered = shutil.which("pymobiledevice3")
if discovered is None:
raise RuntimeError("pymobiledevice3 is not installed in this environment")
return discovered
def pymobiledevice3_version(binary: str) -> str:
completed = subprocess.run(
[binary, "--no-color", "version"],
check=False,
capture_output=True,
text=True,
timeout=20,
)
if completed.returncode != 0:
raise RuntimeError("pymobiledevice3 version check failed")
return completed.stdout.strip()
def usb_device_count(binary: str) -> int:
completed = subprocess.run(
[binary, "--no-color", "usbmux", "list", "--usb"],
check=False,
capture_output=True,
text=True,
timeout=20,
)
if completed.returncode != 0:
raise RuntimeError(
"USB discovery failed; unlock the iPhone and confirm Trust This Computer"
)
try:
payload = json.loads(completed.stdout)
except json.JSONDecodeError as exc:
raise RuntimeError("USB discovery returned an unexpected response") from exc
if not isinstance(payload, list):
raise RuntimeError("USB discovery returned an unexpected response")
return len(payload)
def main() -> int:
try:
binary = pymobiledevice3_binary()
version = pymobiledevice3_version(binary)
count = usb_device_count(binary)
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
print(f"BLOCKED: {exc}")
return 2
print(f"pymobiledevice3: {version}")
print(f"USB iPhone devices: {count}")
if count == 0:
print("BLOCKED: connect one unlocked iPhone with a data cable and confirm Trust")
return 2
if count > 1:
print("BLOCKED: leave exactly one trusted iPhone connected for a redacted capture")
return 2
print("READY: one trusted USB iPhone is available; no device identifier was printed")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,12 @@
[project]
name = "mission-core-ios-capture-lab"
version = "0.1.0"
description = "Isolated read-only iPhone packet-capture tooling for Mission Core lab work"
requires-python = ">=3.12,<3.13"
dependencies = [
"pymobiledevice3==9.36.0",
"python-pcapng==2.1.1",
]
[tool.uv]
package = false

View File

@ -0,0 +1,169 @@
"""Seal one completed iPhone capture without rewriting its original manifest.
The capture manifest is written when the bounded packet capture ends. Operator
events may be appended after that point, so they belong in a separate derived
integrity inventory rather than in a retroactively edited capture manifest.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
INTEGRITY_SCHEMA = "missioncore.xgrids-k1/iphone-session-integrity/v1alpha1"
CAPTURE_MANIFEST = "manifest.redacted.json"
INTEGRITY_INVENTORY = "integrity.private.json"
OPTIONAL_SESSION_ARTIFACTS = {
"capture.stderr.log": "capture-stderr",
"operator-events.private.jsonl": "operator-events",
}
class SessionIntegrityError(ValueError):
"""The session cannot be safely verified or sealed."""
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _load_object(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise SessionIntegrityError(f"cannot read JSON object: {path.name}") from exc
if not isinstance(value, dict):
raise SessionIntegrityError(f"JSON root must be an object: {path.name}")
return value
def _safe_artifact_path(session_dir: Path, relative_path: object) -> Path:
if not isinstance(relative_path, str) or not relative_path:
raise SessionIntegrityError("artifact relative_path must be a non-empty string")
relative = Path(relative_path)
if relative.is_absolute() or ".." in relative.parts:
raise SessionIntegrityError("artifact path must stay inside the session")
path = session_dir / relative
if path.is_symlink() or not path.is_file():
raise SessionIntegrityError(f"artifact is missing or is a symlink: {relative_path}")
return path
def _record(path: Path, *, kind: str, session_dir: Path) -> dict[str, Any]:
before = path.stat()
digest = sha256_file(path)
after = path.stat()
if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
raise SessionIntegrityError(f"artifact changed while hashing: {path.name}")
return {
"kind": kind,
"relative_path": str(path.relative_to(session_dir)),
"size_bytes": after.st_size,
"sha256": digest,
"mode_octal": f"{after.st_mode & 0o777:04o}",
}
def build_inventory(session_dir: Path) -> dict[str, Any]:
"""Verify a completed capture and return a derived integrity inventory."""
session_dir = session_dir.expanduser().resolve()
manifest_path = session_dir / CAPTURE_MANIFEST
if manifest_path.is_symlink() or not manifest_path.is_file():
raise SessionIntegrityError(f"missing {CAPTURE_MANIFEST}")
manifest = _load_object(manifest_path)
if manifest.get("status") != "completed":
raise SessionIntegrityError("only a completed capture can be sealed")
session_id = manifest.get("session_id")
if not isinstance(session_id, str) or session_id != session_dir.name:
raise SessionIntegrityError("manifest session_id does not match the directory")
artifacts = manifest.get("artifacts")
if not isinstance(artifacts, list) or not artifacts:
raise SessionIntegrityError("capture manifest has no artifacts")
verified: list[dict[str, Any]] = []
for item in artifacts:
if not isinstance(item, dict):
raise SessionIntegrityError("manifest artifact must be an object")
path = _safe_artifact_path(session_dir, item.get("relative_path"))
actual = _record(
path,
kind=str(item.get("kind") or "capture-manifest-artifact"),
session_dir=session_dir,
)
if item.get("size_bytes") != actual["size_bytes"]:
raise SessionIntegrityError(f"artifact size mismatch: {actual['relative_path']}")
if item.get("sha256") != actual["sha256"]:
raise SessionIntegrityError(f"artifact hash mismatch: {actual['relative_path']}")
verified.append(actual)
derived = [_record(manifest_path, kind="capture-manifest", session_dir=session_dir)]
for relative_name, kind in OPTIONAL_SESSION_ARTIFACTS.items():
path = session_dir / relative_name
if path.exists():
if path.is_symlink() or not path.is_file():
raise SessionIntegrityError(
f"optional artifact is not a regular file: {relative_name}"
)
derived.append(_record(path, kind=kind, session_dir=session_dir))
return {
"schema": INTEGRITY_SCHEMA,
"session_id": session_id,
"sealed_at_utc": datetime.now(UTC).isoformat(),
"capture_manifest_preserved": True,
"verified_manifest_artifacts": verified,
"derived_session_artifacts": derived,
}
def seal_session(session_dir: Path, *, overwrite: bool = False) -> Path:
"""Write an ignored mode-0600 inventory next to the immutable capture."""
os.umask(0o077)
resolved = session_dir.expanduser().resolve()
destination = resolved / INTEGRITY_INVENTORY
if destination.exists() and not overwrite:
raise SessionIntegrityError(f"{INTEGRITY_INVENTORY} already exists")
inventory = build_inventory(resolved)
temporary = resolved / f".{INTEGRITY_INVENTORY}.tmp"
temporary.write_text(json.dumps(inventory, indent=2) + "\n", encoding="utf-8")
temporary.chmod(0o600)
temporary.replace(destination)
destination.chmod(0o600)
return destination
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Verify and seal one completed Mission Core iPhone capture session"
)
parser.add_argument("session", type=Path, help="completed ignored session directory")
parser.add_argument(
"--overwrite",
action="store_true",
help="replace only the derived inventory; never changes the capture manifest",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
destination = seal_session(args.session, overwrite=args.overwrite)
except SessionIntegrityError as exc:
raise SystemExit(str(exc)) from exc
print(f"sealed: {destination}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,107 @@
from __future__ import annotations
import hashlib
import importlib.util
import json
from pathlib import Path
from types import ModuleType
import pytest
REPOSITORY_ROOT = Path(__file__).parents[1]
INTEGRITY_PATH = (
REPOSITORY_ROOT / "plugins" / "xgrids-k1" / "lab" / "iphone-capture" / "session_integrity.py"
)
def _load_module() -> ModuleType:
spec = importlib.util.spec_from_file_location(
"iphone_capture_session_integrity", INTEGRITY_PATH
)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
INTEGRITY = _load_module()
def _sha256(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
def _session(tmp_path: Path) -> tuple[Path, Path]:
session = tmp_path / "20260716T000000Z_test_abcd"
capture_dir = session / "captures"
capture_dir.mkdir(parents=True)
capture = capture_dir / "iphone-network.pcap"
capture_bytes = b"synthetic-private-capture"
capture.write_bytes(capture_bytes)
manifest = {
"session_id": session.name,
"status": "completed",
"artifacts": [
{
"kind": "iphone-network-pcap-compatible",
"relative_path": "captures/iphone-network.pcap",
"size_bytes": len(capture_bytes),
"sha256": _sha256(capture_bytes),
}
],
}
(session / "manifest.redacted.json").write_text(json.dumps(manifest), encoding="utf-8")
(session / "capture.stderr.log").write_bytes(b"")
events = session / "operator-events.private.jsonl"
events.write_text('{"sequence":1}\n', encoding="utf-8")
return session, capture
def test_seal_session_preserves_manifest_and_hashes_late_operator_events(tmp_path: Path) -> None:
session, _ = _session(tmp_path)
manifest = session / "manifest.redacted.json"
before = manifest.read_bytes()
destination = INTEGRITY.seal_session(session)
inventory = json.loads(destination.read_text(encoding="utf-8"))
assert manifest.read_bytes() == before
assert inventory["capture_manifest_preserved"] is True
assert inventory["verified_manifest_artifacts"][0]["relative_path"] == (
"captures/iphone-network.pcap"
)
derived = {item["kind"]: item for item in inventory["derived_session_artifacts"]}
assert derived["capture-manifest"]["sha256"] == _sha256(before)
assert derived["capture-stderr"]["size_bytes"] == 0
assert derived["operator-events"]["relative_path"] == "operator-events.private.jsonl"
assert destination.stat().st_mode & 0o777 == 0o600
def test_seal_session_rejects_capture_tampering(tmp_path: Path) -> None:
session, capture = _session(tmp_path)
capture.write_bytes(b"tampered")
with pytest.raises(INTEGRITY.SessionIntegrityError, match="size mismatch"):
INTEGRITY.seal_session(session)
def test_seal_session_refuses_to_replace_inventory_without_explicit_flag(
tmp_path: Path,
) -> None:
session, _ = _session(tmp_path)
INTEGRITY.seal_session(session)
with pytest.raises(INTEGRITY.SessionIntegrityError, match="already exists"):
INTEGRITY.seal_session(session)
def test_seal_session_rejects_manifest_path_escape(tmp_path: Path) -> None:
session, _ = _session(tmp_path)
manifest_path = session / "manifest.redacted.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["artifacts"][0]["relative_path"] = "../outside.pcap"
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
with pytest.raises(INTEGRITY.SessionIntegrityError, match="stay inside"):
INTEGRITY.seal_session(session)