Scry Security Audit¶
Date: 2026-04-24
Last remediation pass: 2026-04-24
Scope: android/, scry-connect/, robot-setup/
Posture: Phase 0/1 Alpha. LAN-only threat model per CLAUDE.md. Alpha release not yet distributed.
0. Remediation Status¶
The original audit and the status of each finding after the 2026-04-24 hardening pass. Every C- and H- item is addressed; the auth model was then re-tuned 2026-04-24 (later that day) to align with rosbridge / foxglove_bridge conventions — open by default, opt-in to stricter modes.
Posture change (2026-04-24, second pass)¶
Mandatory auth was friction in practice (users had to SSH into the robot to copy a token before they could debug). After comparing to rosbridge, foxglove_bridge, and rmw_zenoh, we flipped the default to open and made hardening opt-in. The connect stays safe in open mode because:
- RFC1918 / loopback gate rejects callers from public IPs by default.
- Phone-side approval dialog is the human-in-the-loop for writes.
- Safety envelope caps velocity / effort / rate magnitudes server-side.
- Audit log captures every write attempt with source IP.
- Optional deadman switch makes write authority track the phone screen.
Token mode is one CLI flag (--token) and pairs via QR from the phone.
mTLS is --mtls.
| ID | Title | Status |
|---|---|---|
| C-1 | Unauthenticated MCP endpoint | Re-scoped. Default is open mode with an RFC1918 / loopback gate (security.is_local_or_rfc1918). Token (--token, paired via QR) and mTLS (--mtls) modes are opt-in. Public-internet open mode requires explicit --public-internet. |
| C-2 | Client-side-only write gate | Fixed in token / mtls modes. server.call_tool rejects write tools without a one-shot X-Scry-Confirm nonce when auth ≠ open. Android mints the nonce in AiProxyLoop.run after user approval; McpClient.issueConfirmation does the exchange. In open mode the phone approval dialog is the gate; demanding a server nonce there is theatre because a LAN attacker can DDS-publish directly. |
| C-3 | Docker dev stack exposes host X server | Fixed. docker-compose.dev.yml defaults turtlesim to QT_QPA_PLATFORM=offscreen and no longer mounts /tmp/.X11-unix / XAUTHORITY. GUI mode moved to opt-in docker-compose.gui.yml override. |
| H-1 | No ROS safety bounds on write tools | Fixed. safety.SafetyPolicy + safety.check_publish clamp Twist linear/angular, JointTrajectory points, publish rate, and publish count. TopicManager.pub enforces it. Overridable via SCRY_MAX_* env vars and SCRY_ALLOW_UNSAFE_PUBLISH=1. |
| H-2 | Unbounded regex on AI filter args |
Fixed. managers.safe_filter compiles with a 256-char cap, catches re.error, and routes star/question-mark patterns through fnmatch which cannot backtrack. Applied in topic.py, service.py, action.py, param.py, pkg.py, interface.py. |
| H-3 | ros_bag_info accepts arbitrary paths |
Fixed. bag.BagManager._resolve_under_root pins paths under $SCRY_BAG_ROOT (default ~/ros2_bags) with Path.resolve + relative_to. |
| H-4 | OkHttp logging interceptor always on | Fixed. di.AppModule.provideOkHttpClient only attaches HttpLoggingInterceptor when BuildConfig.DEBUG. |
| H-5 | ROS_DOMAIN_ID=0 + host networking |
Partially fixed. Dev compose now defaults to ROS_DOMAIN_ID=42 with a commented ROS_LOCALHOST_ONLY=1 toggle and a prominent warning. Production operators still choose their own domain. |
| M-1 | ros_run_node / ros_run_launch unchecked identifiers |
Fixed. process._validate_ident (lowercase alnum + underscore, length ≤64) plus _LAUNCH_ARG_RE/_ROS_FLAG_RE whitelist for args. |
| M-2 | Room DB unencrypted | Documented in SECURITY.md. No change to the DB layer; Phase 0 accepts plaintext at rest. |
| M-3 | fallbackToDestructiveMigration |
Documented. Acceptable for alpha; flag in release checklist. |
| M-4 | Raw exception messages leak | Fixed. server.call_tool returns ManagerError details verbatim (expected surface) but maps everything else to {"error": "internal_error", "error_id": "..."} and logs the traceback server-side. |
| M-5 | _build_condition attribute traversal defensive note |
Unchanged. Still only reached via msg_to_dict'd records per watcher.py:81. Added to SECURITY.md as a maintained invariant. |
| M-6 | MainActivity launchMode |
Fixed. android:launchMode="singleTask" added to AndroidManifest.xml. |
| M-7 | Singleton OkHttp across cloud + LAN | Documented — NSC already denies cleartext to current AI hosts. Adding a new cloud provider is a reviewable diff because network_security_config.xml is also touched. |
| L-1..L-8 | Tracked in the body; none block alpha. | |
| I-10 | No SECURITY.md / threat model | Fixed. SECURITY.md at repo root, referenced from README. |
Blocker list verdict: all C-tier and H-tier items closed in code. Medium items are either fixed, documented with explicit trade-offs, or deferred to the release checklist.
1. Executive Summary¶
Scry is a phone-proxied AI robot controller. The Android app is reasonably well-built for an alpha (EncryptedSharedPreferences for BYOK keys, write-tool confirmation in the proxy loop, narrow networkSecurityConfig). The connect, by contrast, is unauthenticated and executes every tool — including hardware-control writes — that reaches /mcp/. The entire "write operations require user confirmation" guarantee documented in CLAUDE.md lives exclusively on the Android client; any process on the LAN can bypass it.
The top risks, in order:
- C-1 Unauthenticated MCP endpoint executes write tools.
scry-connect/scry_connect/server.py:48-77— no auth middleware, no confirmation header check,is_write_tool()never consulted. - C-2 Write-gate parity is a client-side honor system.
android/.../domain/usecase/McpToolCatalog.kt:44vsscry-connect/.../tools/registry.py— enforced only byWriteToolParityTest. Nothing prevents a rogue MCP caller from invokingros_publish_topicon/cmd_velwith arbitrary values. - H-1
docker-compose.dev.ymlbinds the connect on0.0.0.0with host networking while simultaneously mountingXAUTHORITYand/tmp/.X11-unixfor theturtlesimservice. A LAN peer can drive the robot and (on the dev host) reach the X server. - H-2 No ROS safety bounds on write tools.
ros_publish_topicaccepts anydatadict (managers/topic.py:240); no velocity/acceleration clamps oncmd_vel-shaped topics. If the AI hallucinates or an attacker owns the connect, physical damage is possible. - H-3 Topic-name and regex-filter inputs are unvalidated.
filterparameters across tools are fed directly tore.search(managers/topic.py:56,managers/param.py:28, others) — catastrophic-backtracking regexes produced by the AI can freeze handlers inside the shared rate-limited event loop.
Nothing is immediately exploitable from the cloud (no public endpoints, no secrets in repo). Risk is entirely on the LAN trust boundary, which is explicitly accepted in cli.py:24 help text but has not been surfaced to end users in README/install.sh.
2. Critical Findings¶
C-1 — Connect HTTP endpoint is fully unauthenticated¶
- Severity: Critical
- Component: Scry Connect
- Files:
scry-connect/scry_connect/server.py:48-77,scry-connect/scry_connect/cli.py:22-24,robot-setup/docker-compose.dev.yml:48 - Description: The Starlette app mounts
/mcpwith no auth middleware.call_tooldispatches any registered tool, including the 22 write-gated ones, without checkingis_write_tool()or any caller identity. The CLI's--publicflag binds0.0.0.0:5339and the dev compose file hardcodes--host 0.0.0.0withnetwork_mode: host. - Impact: Any device on the same WiFi/LAN (including guest networks, coffee-shop APs, compromised IoT) can list tools and invoke
ros_publish_topic,ros_call_service,ros_send_action_goal,ros_run_node,ros_set_parameter,ros_lifecycle_set,ros_control_switch_controllers, etc. On a real robot this is direct physical control. - Remediation:
- Require a shared secret header (
X-Scry-Token) on/mcpand/stream. Generate on first launch, write to~/.config/scry/token, display as QR for the app to scan. - Add a Starlette middleware that rejects requests lacking the token with 401. Default-deny.
- Have
call_tooladditionally require anX-Scry-Confirm: <tool-use-id>header for any tool whereis_write_tool(name)is true. The Android app must echo the approved tool-use id. - Change
cli.pydefault to127.0.0.1, make--publicrequire either--tokenor--insecure-no-authto choose explicitly.
C-2 — Write-gate / confirmation flow is client-side only¶
- Severity: Critical
- Component: Cross-component (Android ↔ Connect trust boundary)
- Files:
android/app/src/main/java/com/scry/domain/usecase/AiProxyLoop.kt:150-204,android/.../McpToolCatalog.kt:44-73,scry-connect/scry_connect/server.py:48-77,scry-connect/scry_connect/tools/registry.py:39-41 - Description:
AiProxyLoop.runsplits pending tool calls into reads (parallel) and writes (await user approval). The connect'scall_toolhas no equivalent gate — it callsreg.handler(arguments)regardless ofreg.write. The two write sets can drift; parity is guarded only byandroid/app/src/test/java/com/scry/domain/usecase/WriteToolParityTest.kt. - Impact: Defeats the central safety claim in
CLAUDE.md("All write operations require user confirmation"). An attacker (or a local command-line curl) bypasses the phone entirely. - Remediation: Pair C-1 token auth with a server-side write-gate: the Android approval UI issues a one-shot confirmation token per tool use id, included as a request header.
call_toolrejects any write tool whose token is missing/used/unexpired. KeepWriteToolParityTestbut make it verify the server-side set, not the client-side mirror.
C-3 — Docker dev stack exposes host X server alongside unauthenticated connect¶
- Severity: Critical (dev-only, but will be pattern-copied)
- Component: robot-setup
- Files:
robot-setup/docker-compose.dev.yml:82-84 - Description:
turtlesimmounts/tmp/.X11-unixand$XAUTHORITYinto a container that sharesnetwork_mode: host. Combined with the unauthenticated:5339endpoint in the same compose file, a LAN peer who exploits anything inside the container (or who controls the connect via C-1) reaches the developer's X display. - Impact: Dev-machine compromise beyond the ROS surface — keystrokes, screen capture, arbitrary X input.
- Remediation: Default
QT_QPA_PLATFORM: offscreen(the commented branch) and remove the X11 mounts from the default. Move GUI mode behind an opt-indocker-compose.gui.ymloverride with a prominent README warning.
3. High Findings¶
H-1 — No velocity / bound checks on robot-control publishes¶
- Files:
scry-connect/scry_connect/managers/topic.py:240-263,scry-connect/scry_connect/tools/registry.py:163-176 - Description:
ros_publish_topicaccepts anydatadict and anytimes/rate_hzwithin[1..1000]. Forgeometry_msgs/Twiston/cmd_velthis means the AI can publish arbitrary linear/angular velocities at 1 kHz. There is no per-topic safety validator, no velocity clamp, no emergency-stop interlock. - Impact: A hallucinated tool call (or a C-1-abusing attacker) can drive a real robot into walls or people. ROS 2 has no middleware-layer throttle.
- Remediation:
- Add a configurable safety policy (
config.py) per-topic type: max linear/angular velocity, max effort, max joint-trajectory step. Reject publishes that exceed it server-side with a clear error. - Require an explicit opt-in (
SCRY_ALLOW_UNSAFE_PUBLISH=1) for tele-op use cases. - Consider always inserting a deadman topic
scry/enablethat the Android app must publish at ≥2 Hz; the connect drops writes when stale.
H-2 — Unbounded regex on AI-controlled filter args (ReDoS)¶
- Files:
scry-connect/scry_connect/managers/topic.py:56,managers/param.py:28,managers/service.py,managers/action.py,managers/pkg.py:28,managers/interface.py - Description:
filterargs are passed directly tore.search(filter_pattern, name). The AI model emits these; nothing prevents a pathological pattern like(a+)+$. One bad filter synchronously blocks the asyncio event loop handling every other MCP request. - Impact: Single-tool DoS hangs the connect (rate-limited tokens accumulate but handlers can't run).
- Remediation: Compile with
re.compile(pattern)inside atry/except re.error; reject patterns longer than N chars; run the match insideasyncio.to_threadwith a short timeout; or preferfnmatch-style globs for these filters.
H-3 — ros_bag_info accepts arbitrary paths¶
- Files:
scry-connect/scry_connect/managers/bag.py:15-17,tools/registry.py:681-684 - Description:
ros_bag_infopasses the user/AI-providedpathstraight toros2 bag info <path>with no allowlist. While there is no shell interpolation (arg list subprocess), the CLI will read whatever file path it's given and expose metadata about it in the response. - Impact: Information leak of bag file locations on the robot. Not RCE, but lets an attacker enumerate the filesystem for bag targets.
- Remediation: Constrain to a configurable root (e.g.
~/ros2_bags) similar to the pattern inpkg.py:92-98, or require a relative path + resolve under an allowed directory.
H-4 — OkHttp logging interceptor at BASIC level in all builds¶
- Files:
android/app/src/main/java/com/scry/di/AppModule.kt:51 - Description:
HttpLoggingInterceptor(BASIC)is installed unconditionally (noif (BuildConfig.DEBUG)). BASIC only logs method/url/status, but still leaks robot LAN IPs and Ollama URLs on release-build logs readable viaadb logcaton the user's device and any crash collector. - Impact: Low in isolation but becomes High if someone upgrades the level, or pairs with a log-exfil vulnerability.
- Remediation: Wrap with
if (BuildConfig.DEBUG) builder.addInterceptor(logging).
H-5 — Dev compose pins ROS_DOMAIN_ID: "0" on host networking¶
- Files:
robot-setup/docker-compose.dev.yml:26-27 - Description: Domain 0 + host networking + default CycloneDDS/FastRTPS behavior means the connect participates in every ROS 2 graph on the LAN. Combined with C-1, any ROS 2 node on the network is visible and reachable.
- Remediation: Document the default, encourage
ROS_DOMAIN_ID> 0 andROS_LOCALHOST_ONLY=1for single-machine development.
4. Medium Findings¶
M-1 — ros_run_node / ros_run_launch accept any package/executable name¶
- Files:
scry-connect/scry_connect/managers/process.py:57-88 - Description:
packageandexecutablego straight intoasyncio.create_subprocess_exec(["ros2", "run", package, executable, …]). No shell, so no injection, but also no validation that the names are legitimate ROS package identifiers. Combined with C-1 an attacker can spawn up to 20 concurrent processes (MAX_PROCESSES = 20). - Remediation: Validate
package/executableagainst the same regex used inpkg.py([a-z][a-z0-9_]{1,63}) before exec.
M-2 — Room DB is not encrypted; chat history persisted¶
- Files:
android/app/src/main/java/com/scry/di/AppModule.kt:26-29,android/.../data/local/ScryDatabase.kt - Description: Standard Room (unencrypted SQLite). Chat history (which may contain robot internal state) and saved-robot hosts/ports are stored in plaintext. Acceptable for a debugging tool but worth noting.
- Remediation: Optional — SQLCipher/Jetpack Security EncryptedFile if sensitive operational data is expected. At minimum: document that chat history is unencrypted at rest so users don't paste secrets.
M-3 — fallbackToDestructiveMigration() enabled¶
- Files:
android/.../di/AppModule.kt:28 - Description: Silent schema wipes on every migration failure. Fine for alpha; flag before release.
M-4 — Connect exception messages propagate raw to client¶
- Files:
scry-connect/scry_connect/server.py:72-77 - Description:
return {"error": str(e), "tool": name}returns raw exception text including file paths, parameter internals, rclpy stack context. Pairs with C-1 to aid reconnaissance. - Remediation: For non-
ManagerErrorexceptions, return a generic "internal error" and log details server-side. Keep the specificInvalidArgument/NotFound/Timeoutmessages since they are expected.
M-5 — Condition expression _build_condition parser permits arbitrary attribute traversal¶
- Files:
scry-connect/scry_connect/managers/watcher.py:317-324 - Description:
_resolve_pathusesgetattr(cur, p)whencuris not a dict. Because watcher callbacks receive ROS msg objects before they're dict-converted… actuallymatcheris called on the alreadymsg_to_dict-encodedrecord["data"](line 81), so this is safe. Flagging defensively: if future refactors pass raw ROS messages thegetattrbranch becomes an introspection primitive against arbitrary Python objects. - Remediation: Harden
_resolve_pathto dict-only now, before the invariant is forgotten.
M-6 — MainActivity is exported="true" with no deep-link intent filter beyond LAUNCHER¶
- Files:
android/app/src/main/AndroidManifest.xml:39 - Description: Standard for a launcher activity, but the manifest does not set
android:launchModeandChatViewModelaccepts user input immediately on foreground. No current intent-based attack surface but worth keeping narrow —exported="true"is necessary for LAUNCHER only, not for any deep-link/view intents that may be added later. - Remediation: Add
android:launchMode="singleTask"and keep the intent filter list minimal.
M-7 — Hilt @Singleton OkHttpClient shared across robot and cloud AI calls¶
- Files:
android/.../di/AppModule.kt:49-57 - Description: One client for both plaintext robot-LAN traffic and TLS calls to
api.anthropic.com. NetworkSecurityConfig prevents cleartext to the named domains, so TLS is enforced, but if a new AI provider is added without updatingnetwork_security_config.xmlit silently inherits cleartext allowance. - Remediation: Either (a) flip the NSC default to
cleartextTrafficPermitted="false"and explicitly whitelist RFC1918 via per-IPdomain-configblocks where possible, or (b) add a lint/test that everyAiClientURL host is present in NSC.
5. Low Findings¶
L-1 — install.sh uses curl | bash pattern¶
- Files:
robot-setup/install.sh:5, README. Industry-standard but opaque. Document the SHA256 of the script, or prefer a pip-only install path.
L-2 — RECORD_AUDIO and CAMERA permissions declared at manifest level (AndroidManifest.xml:7-8). Justified (voice input, image attach) but should be requested at runtime, which the code does through TapToTalkMic.kt — verify runtime consent still shown before first use.¶
L-3 — lastActiveRobotId is stored in encrypted prefs (SecurePrefs.kt:84-86) though it's not sensitive. Harmless; just notes that the encryption overhead is non-zero per-read for a value that could live in plain prefs.¶
L-4 — network_security_config.xml allowlist covers only Claude, OpenAI, Gemini. Ollama is cleartext by design (LAN) but any future cloud provider (Groq, xAI, etc.) will silently fall through to the cleartext-allowed base config.¶
L-5 — No PII scrubbing on chat logs sent to AI providers. A user typing a hostname, IP, or robot serial goes straight to Anthropic/OpenAI. Document in privacy notice.¶
L-6 — SSE topic stream (streaming/sse.py) inherits the same no-auth posture as /mcp. Read-only, but exposes live sensor data to LAN peers. Subsumed by C-1 remediation.¶
L-7 — Dockerfile.dev is not audited here (not read); ensure it does not install packages as root without pinning.¶
L-8 — scry-connect.service (systemd) uses Restart=on-failure with no User=/Group= — service runs as the invoking user, which is typical but should be documented. Explicitly reject running as root.¶
6. Info¶
- I-1 Positive:
SecurePrefs.ktusesEncryptedSharedPreferences(AES256-GCM) with properbackup_rules.xml/data_extraction_rules.xmlexclusions. Good. - I-2 Positive:
network_security_config.xmlexplicitly forbids cleartext to cloud AI providers — correct defense-in-depth. - I-3 Positive:
shell_runner.pyconsistently uses arg-listcreate_subprocess_exec, nevershell=True. Grep confirms noshell=True/os.system/eval/pickle.loads/yaml.loadanywhere. - I-4 Positive:
pkg.py:91-98correctly validatesdestination_directoryagainst$HOMEviaos.path.realpath— good path-traversal defense. - I-5 Positive:
PkgManager.createregex-validates package names, node names, and dependency names against strict identifiers. - I-6 Positive:
isMinifyEnabled = trueandisShrinkResources = truefor release (android/app/build.gradle.kts:27-28). - I-7 Positive:
allowBackup="false"anddebuggablenot forced (AndroidManifest.xml:26). - I-8 Positive: Watcher expressions avoid
eval— parsed manually by_build_condition. Good. - I-9 Positive: Rate limiter (
rate_limiter.py) guards against runaway AI loops. - I-10 Gap: No
SECURITY.md, no documented threat model, no vulnerability-disclosure contact.
7. Defense-in-Depth Recommendations¶
- Server-side write-gate. Even after C-1 token auth lands, keep a server-enforced
is_write_tool()check with a distinct confirmation nonce. Never trust the phone alone. - Typed safety envelope for publishes. Encode per-message-type max magnitudes (Twist, JointTrajectory, JointCommand) in a YAML config loaded at connect start. Reject out-of-envelope values with a structured
SafetyViolationerror surfaced in the Android approval dialog. - Audit log. Append every write-tool invocation (tool, args, caller, timestamp, success) to
~/.local/state/scry/audit.jsonlfor post-incident review. - Deadman switch. Android publishes a
/scry/enablebool at 2 Hz while the chat is active; connect drops write tools if the latch is stale >1 s. - mTLS option. For production deployments, offer a
--cert/--keyflag pair. Ship ascry-connect gen-certhelper that outputs a self-signed cert + the fingerprint for the app to pin. - SROS2 documentation. The current architecture sidesteps DDS security entirely. Document when users should enable SROS2 and what it buys them vs scry token auth.
- Per-IP allowlist on connect.
--allow 192.168.1.0/24flag for users who want LAN-scoped but not promiscuous. - Dependency scanning in CI. Add
pip-auditonscry-connectand./gradlew dependencyCheckAnalyze(OWASP) on the app. No CVEs were identifiable from pyproject/gradle versions at audit time, butmcp>=1.0.0/starlette>=0.36.0are unpinned floors. - Clarify trust boundary in README.
cli.py:24warns "do not use on shared networks" butREADME.mdandinstall.shdo not. Users read those first. - Android release signing checklist. Not yet present — add before Play-store upload.
8. Pre-Deployment Checklist (BLOCKERS)¶
Before any deployment outside the author's single-host dev loop:
- C-1 Connect requires a shared token for
/mcpand/stream; default bind is127.0.0.1;--publicrequires--token. - C-2
call_toolenforcesis_write_tool()with a per-invocation confirmation nonce issued by the Android approval UI. - C-3 Dev
docker-compose.dev.ymldefaults toQT_QPA_PLATFORM=offscreen; GUI mode moved to an opt-in override file. - H-1 Per-topic-type safety envelope in connect config;
ros_publish_topicrejects out-of-bounds messages. Document the default envelope forgeometry_msgs/Twist. - H-2
filterregex args compiled with length cap +re.errorhandling; matches run off the event loop or use a glob. - H-3
ros_bag_infopath constrained to a configured bag root. - H-4
HttpLoggingInterceptorwrapped inBuildConfig.DEBUG. - M-1
ros_run_node/ros_run_launchvalidate package/executable identifiers. - M-4 Generic error envelope for unexpected exceptions in
server.call_tool. - README / install.sh surface the LAN trust assumption and token setup steps prominently.
- Add
SECURITY.mdwith disclosure contact. - CI gates:
pip-auditfor connect,./gradlew test lintfor Android,WriteToolParityTestmust pass. - Release APK signed with v2+v3 signature,
isDebuggableexplicitlyfalsefor release (currently relies on default — add an assertion).
End of audit.