Skip to content

fix(device-shell): one typed device-shell boundary for adb, exec-out, and hdc - #2611

Open
thymikee wants to merge 5 commits into
mainfrom
claude/shell-safe-boundary
Open

thymikee wants to merge 5 commits into
mainfrom
claude/shell-safe-boundary

Conversation

@thymikee

@thymikee thymikee commented Sep 14, 2026

Copy link
Copy Markdown
Member

Summary

Closes #2026. adb shell, adb exec-out, and hdc shell join their argv into one string the device sh re-parses, so any unquoted dynamic element was a device-side command injection. Android quoted a few sites by hand; HarmonyOS quoted nothing (uitest uiInput text passed user text raw).

@agent-device/kernel/device-shell is now the only producer of a device-shell argv:

  • deviceShellArgv(subcommand, words, prefix?) quotes every word (identity on the safe charset, so migrated sites are byte-identical unless the word was an injection vector).
  • shellFragment(script) is the one greppable escape hatch for genuine shell syntax (6 sites, each with shellQuote-d interpolations).
  • assertDeviceShellArgv refuses a shell/exec-out argv the funnel did not build, at every boundary: handed-out adb executors and providers, runAndroidHostAdb, runHarmonyHdc, and the host command port for adb/hdc. Acceptance is by identity only; a relay that rebuilds the array (limrun serial prefix, provider-scope serial strip, managed server-port rewrite, host command port) carries the identity across with relayDeviceShellArgv. A plain copy is refused loudly, never silently downgraded.
  • Funnels: runAndroidShell/runAndroidExecOut, runAdbShell/runAdbExecOut (exported from Android mechanics for injected executors), runHarmonyShell (exported from the HarmonyOS entry). shellQuote moves from host-kit to the kernel module (one implementation). Executor argv is readonly end to end; no casts. SDK note (in CHANGELOG): a custom executor annotated (args: string[]) must widen to readonly string[].

Behavior changes (in CHANGELOG): metacharacter words arrive single-quoted (the HarmonyOS fix); an empty word renders as '' instead of vanishing; sh -c script bodies reach sh as one argument; a raw ['shell', …] from a custom executor is refused with INVALID_ARGS (unguarded-device-shell-argv).

152 files touched. Gross diff exceeds the 1,000-line budget by design: #2026's scope decision requires mechanism, guards, and every call site in one PR, since a typed seam existing callers bypass is false confidence.

Validation

Tested at a3a0ea2163 (rebased onto main; the last two commits are the SDK doc snippet annotation and a size trim).

  • pnpm check:tooling (format, lint, typecheck, layering, depgraph, build, package): pass.
  • pnpm test:unit: 1306 files, 10085 tests pass. Provider-integration scenarios: 66 files pass.
  • pnpm check:fallow: no issues in changed files.
  • pnpm check:affected --run: on f97c77e169 three tests hit their 5 s timeout under the bundle's contention (test-file-size-ratchet, ios-record-trace, apple runner-client); all three pass in isolation and the PR touches none of those paths. On 1006591fee the only failure was mutation-model, whose ownership.test.ts "a kernel is owned by tests that reach it indirectly" also fails at the merge-base ad9b906140 (verified in a clean checkout of origin/main). The doc-snippet test (client-api-doc-snippets) passes on the final head.
  • Live Android (emulator-5554, fresh pnpm build + clean:daemon, isolated state dir), zero unguarded-device-shell-argv refusals in the daemon log:
    • open com.android.settings, click the search field, type/fill with wifi; echo pwned $(id) 'x' `uname` landed literally (snapshot shows the exact string, no substitution); screenshot via exec-out succeeded.
    • record start/record stop: 57 KB playable MP4 through the screenrecord … & echo $! and printf … > tmp && mv fragments.
    • perf memory sample and perf frames succeed; perf trace start/stop --kind perfetto produces a 327 KB trace through the background-command fragment.
    • perf cpu profile start --kind simpleperf reaches simpleperf, which the emulator refuses (failed to open perf event file … Permission denied); the fragment parsed correctly, the emulator lacks perf events.
    • Snapshot-helper output-file read: the new sh -c '<script>' name path shape run over raw adb shell prints the file and removes it (exit 0); the old unquoted shape fails with sh: exit: too many arguments and leaves the file, so that route was broken before this PR.
  • Provider-scoped Android relay: limrun-runtime.test.ts drives am start through the real limrun serial-prefix relay into runAndroidHostAdb, and device-shell.test.ts sends a module constant through prefix and strip relays after 5,000 other mints. No limrun account is available here for a live run.
  • HarmonyOS: no target attached; unit coverage only (hdc-shell.test.ts proves refusal and quoting at the hdc boundary).

Not covered: AndroidAdbSpawner (background spawn) is not guarded; the only shell spawn sites (helper session, app-log transport) build their argv from the funnel already.

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.54 MB 4.54 MB +1.8 kB
Package (unpacked) 4.53 MB 4.54 MB +1.8 kB
Package (download) 1.34 MB 1.34 MB +754 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.6 ms 27.3 ms -0.2 ms
CLI --help 78.2 ms 79.4 ms +1.2 ms

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 1392bf3. The funnel closes the injection paths #2026 describes, but relayed Android providers can start refusing commands after a long session.

The limrun route is accepted by content, and that record expires. mintedDeviceShellCommands is capped at 1024 entries, FIFO, and not refreshed on re-add (device-shell.ts:75). The limrun path copies the argv (executeLimrunAndroidAdb builds ['-s', serial, ...args] before assertDeviceShellArgv), so it passes only by content match. WINDOW_DUMPS and ACTIVITY_DUMPS in observation.ts are minted once at module load. After about 1024 distinct device-shell commands (each tap and text chunk is distinct), their keys are evicted, and readAppState and readBlockingDialog on a limrun device throw INVALID_ARGS unguarded-device-shell-argv. The PR's own test shows [...constant] throwing after 2000 mints. Could the brand travel through the relay instead, for example a withTransportPrefix(argv, prefix) helper that re-mints, with a regression test that sends a module constant through the limrun relay after more than 1024 mints?

A design question: could the invalid state be unrepresentable instead of detected? If deviceShellArgv returned a branded readonly type and the executors took NonShellAdbArgs | DeviceShellArgv, with a brand-keeping prefix helper for relays, the content-keyed set, its eviction and the runtime refusals at four boundaries would go away. A cheap identity check could stay for untyped callers such as MCP and the host command port. That needs the AndroidAdbExecutor and HostCommandRequest.args contract types to accept a branded argv. If you rejected this shape, what blocked it?

The new hdcShell closure in platform-runtime-screen-recording-harmony-host.ts:63 repeats runHarmonyShell from packages/platform-harmonyos/src/hdc.ts, because the package entry exports only runHarmonyHdc. deployment/runtime.ts and the Android recording host re-spell runAdbShell the same way. Could the shell helpers be exported instead, so each platform has one funnel?

Moving shellQuote and shellQuoteIfNeeded into device-shell.ts, whose header says the host never runs a shell, pulls host-side callers (CLI hints, daemon client metadata, the Apple runner host env export) into the mint registry's module. That reverses the ownership #2595 just settled. Could they live in a neutral kernel/shell-quote module that device-shell imports?

HarmonyOS type and fill text with spaces or metacharacters now arrives as one single-quoted argument (input-actions.ts:39). That is user-visible and needs a CHANGELOG line. The PR body also counts 7 shellFragment sites; I count 6.

Live evidence is still missing for two changed routes: a relayed Android provider such as limrun, and HarmonyOS type/fill with spaces and metacharacters on a device.

Smoke Tests was still running at review time. The diff touches every Android adb shell and exec-out call site, so a failure there would need its own look.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 1392bf3. One funnel for device-shell argv is the right fix for #2026, but the guard design has a failure mode, and a few pieces are duplicated.

device-shell.ts#L44: a copied argv is accepted only while its content key is still in mintedDeviceShellCommands, which holds 1024 entries, evicts the oldest first, and does not refresh a key that is minted again. observation.ts mints WINDOW_DUMPS and ACTIVITY_DUMPS once at module load. As far as I can trace, a limrun-scoped Android session copies that argv in executeLimrunAndroidAdb (provider-limrun/src/android.ts#L203) and runAndroidHostAdb then checks it by content. After about 1024 other distinct mints (taps, typed chunks), the dumpsys read would throw INVALID_ARGS, so foreground and dialog observation starts failing in long sessions. The test "keeps a long-lived minted argv accepted…" asserts this refusal for a copied constant. Can these be minted at read time, like app-state.ts and window-state.ts already do?

platform-runtime-android-tool-host.ts#L36: because the guard is identity-based, the PR drops the [...args] copies, widens AndroidObservationHost.runAdb from readonly string[] to string[], and adds an args as string[] cast. A later defensive copy would silently fall back to the evictable path above. Could a branded argv type keep readonly without the cast?

platform-runtime-screen-recording-harmony-host.ts#L63: hdcShell repeats runHarmonyShell because the HarmonyOS entry exports only a deferred runHarmonyHdc. Could the entry export a deferred runHarmonyShell instead, so there is one HarmonyOS shell funnel?

platform-runtime-host-device-shell.ts#L9: the executable normalization repeats isAdbCommand in adb-provider-scope.ts#L249. Could both use one shared helper?

daemon-client-metadata.ts#L263: host-shell quoting for CLI and daemon recovery hints now imports from @agent-device/kernel/device-shell. Those strings quote for the user's shell, not the device, and this moves the code #2595 just placed. Could shellQuote stay in a neutral kernel module, with device-shell importing it?

There is no CHANGELOG entry, but users will see changes: HarmonyOS type/fill text is now quoted, an empty word renders as '', sh -c bodies reach sh as one argument, and a raw ['shell', …] argv from a custom executor is now refused with INVALID_ARGS. Could an Unreleased entry name the refusal and the funnels?

Would a smaller design do? If deviceShellArgv returned a branded DeviceShellArgv and the adb/hdc executors rendered it themselves, relays would pass it without copying. That removes the WeakSet, the 1024-entry FIFO, the readonly widening and cast, and most of assertDeviceShellArgv; a cheap check for a literal shell at args[0] could still guard plugins. The AndroidAdbExecutor/HostCommandRequest types and the provider relays would have to accept the branded value first.

The live Android run in the description covers open/click/type/fill/screenshot. Could you also run a limrun or provider-scoped Android session, the native perf and screen-recording paths, and the snapshot-helper output-file read whose sh -c quoting changed? For HarmonyOS, typing text with a space would show whether hdc passes the quotes through literally.

Smoke Tests is still running, and the diff sits on every Android shell call it makes. There are no conflicts.

thymikee added a commit that referenced this pull request Sep 14, 2026
…package entries

Review follow-ups on #2611. The content-keyed acceptance window is gone:
a minted argv is accepted by identity only, and a relay that rebuilds the
array (limrun serial prefix, provider-scope serial strip, the managed
server-port rewrite, the host command port copy) carries the identity across
with relayDeviceShellArgv, so a module-level constant stays accepted after
any number of other mints and a plain copy is refused loudly instead of
silently falling back to an evictable path. Executor argv is readonly end to
end, so the observation host keeps its readonly contract with no cast, and
observation mints its dumpsys argv at read time.

runHarmonyShell is exported from the HarmonyOS entry and runAdbShell from
Android mechanics, so the recording hosts use the package funnels instead of
re-spelling them. deviceShellExecutableOf is the one adb/hdc normalizer for
the host command port and the provider scope. The changelog names the
refusal, the funnels, and the user-visible quoting changes.
@thymikee

Copy link
Copy Markdown
Member Author

Addressed at 1006591fee.

Eviction. The content-keyed window is gone. Acceptance is identity-only, and every relay that rebuilds the array carries the identity across with relayDeviceShellArgv: the limrun serial prefix (executeLimrunAndroidAdb), the provider-scope serial strip, the managed server-port rewrite in the root adb host, and the host command port copy. A plain copy is refused loudly at the next boundary rather than falling back to anything. device-shell.test.ts sends a module constant through prefix and strip relays after 5,000 other mints, and limrun-runtime.test.ts already drives am start through the real limrun relay into runAndroidHostAdb. observation.ts also mints at read time now, though nothing depends on that anymore.

Branded type instead of a runtime guard. What blocked it: AndroidAdbExecutor is the provider contract, implemented by limrun, the SDK's limrun-runtime-dependencies, and every fixture provider, and the host side spawns from runCmd(cmd, string[]). A branded object argv would force every implementer and relay to render, and HostCommandRequest is shared with xcrun/hdc. A branded array type is compile-time only, so a relay copy would still need the runtime identity to survive; the guard is what makes a variable-built argv fail. So the split is: types carry readonly string[] end to end (no cast, no widening; the observation contract is readonly again), identity carries the mint at runtime, and the four boundaries stay.

Funnels on the entries. runHarmonyShell is a deferred export of the HarmonyOS entry and runAdbShell/runAdbExecOut are exported from Android mechanics; both recording hosts use them. deployment/runtime.ts keeps a local helper because it runs through the generic host command port (commands.run), where the argv has to carry the -t <target> prefix; that helper is deviceShellArgv with a prefix, which is the host-port funnel.

shellQuote home. I tried the neutral kernel/shell-quote module with device-shell importing it. The eager-closure gate refuses it: five entries (src/cli.ts and three Apple facades on the host side, Android mechanics on the device side) grow by one module because the split replaces one module with two. The gate allows no growth on an existing entry. One implementation therefore has to live in device-shell.ts; the header now says the same POSIX rule serves host-side hints. If you would rather rename the module to something neutral (kernel/shell), that is a mechanical follow-up.

CHANGELOG has an Unreleased entry naming the refusal, the funnels, and the three visible quoting changes. Fragment count corrected to 6.

Live evidence added to the PR body: record start/stop, perfetto, perf memory/frames, simpleperf (reaches the tool; the emulator refuses perf events), and the sh -c read shape run on the device, where the old unquoted shape fails with exit: too many arguments. No limrun account or HarmonyOS target is available here; those remain unit-only.

@thymikee

Copy link
Copy Markdown
Member Author

Addressed at 1006591fee; the details are in my reply on the first review.

  • device-shell.ts#L44: the FIFO window is gone. Identity-only acceptance, relays carry it with relayDeviceShellArgv, observation.ts mints at read time, and the regression test sends a module constant through prefix and strip relays after 5,000 other mints.
  • platform-runtime-android-tool-host.ts#L36: no cast. AndroidAdbExecutor, AndroidAdbSpawner, the host port, runAndroidAdb, runAndroidHostAdb, runHarmonyHdc, and the limrun executor take readonly string[]; AndroidObservationHost.runAdb is readonly again.
  • platform-runtime-screen-recording-harmony-host.ts#L63: the HarmonyOS entry exports a deferred runHarmonyShell, and the host uses it. Android mechanics exports runAdbShell/runAdbExecOut for the Android host.
  • platform-runtime-host-device-shell.ts#L9: deviceShellExecutableOf in the kernel module is the one normalizer; the provider scope's isAdbCommand uses it.
  • daemon-client-metadata.ts#L263: the neutral kernel/shell-quote split fails the eager-closure gate on five entries (one module replaced by two). One implementation stays in device-shell.ts; a rename to a neutral name is a mechanical follow-up if you want it.
  • CHANGELOG: Unreleased entry names the refusal, the funnels, and the visible quoting changes.
  • Live runs: recording, perfetto, perf memory/frames, simpleperf (emulator refuses perf events; the command line parsed), and the sh -c output-file read shape on the device (old shape fails, new shape works). No limrun account or HarmonyOS target here.

@thymikee

Copy link
Copy Markdown
Member Author

One more commit, f97c77e169: the readonly executor argv is a visible SDK type change, so the client-api.md snippet now annotates its custom executor as (args: readonly string[]) and the CHANGELOG entry says so. check:affected on this head: three 5 s timeouts under contention (test-file-size ratchet, ios-record-trace, apple runner-client), each green in isolation and none on a path this PR touches.

… and hdc

Closes #2026.

adb shell, adb exec-out, and hdc shell join their argv after the subcommand
into one string the device sh re-parses, so every unquoted dynamic element
was a device-side command injection. Android quoted a handful of sites by
hand; HarmonyOS quoted nothing (uitest uiInput text passed user text raw).

@agent-device/kernel/device-shell is now the only producer of a device-shell
argv: deviceShellArgv quotes every word (identity on the safe charset, so a
migrated site is byte-identical unless the word was an injection vector),
shellFragment is the one greppable escape hatch for genuine shell syntax, and
assertDeviceShellArgv refuses a shell/exec-out argv the funnel did not build
at every executor boundary: the handed-out adb executors and providers,
runAndroidHostAdb, runHarmonyHdc, and the generic host command port for
adb/hdc. Funnels: runAndroidShell/runAndroidExecOut, runAdbShell/runAdbExecOut
for injected executors, runHarmonyShell. shellQuote moves from host-kit to the
kernel module so the three platform packages share one implementation.

Behavior changes: a word with shell metacharacters now arrives single-quoted
(the HarmonyOS fix), an empty word renders as '' instead of vanishing, and
sh -c script bodies reach sh as one argument.
The provider-scenario fixtures matched a whole command line held in one
argv element; the funnel now emits words, and safe paths arrive unquoted.
…package entries

Review follow-ups on #2611. The content-keyed acceptance window is gone:
a minted argv is accepted by identity only, and a relay that rebuilds the
array (limrun serial prefix, provider-scope serial strip, the managed
server-port rewrite, the host command port copy) carries the identity across
with relayDeviceShellArgv, so a module-level constant stays accepted after
any number of other mints and a plain copy is refused loudly instead of
silently falling back to an evictable path. Executor argv is readonly end to
end, so the observation host keeps its readonly contract with no cast, and
observation mints its dumpsys argv at read time.

runHarmonyShell is exported from the HarmonyOS entry and runAdbShell from
Android mechanics, so the recording hosts use the package funnels instead of
re-spelling them. deviceShellExecutableOf is the one adb/hdc normalizer for
the host command port and the provider scope. The changelog names the
refusal, the funnels, and the user-visible quoting changes.
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto main (two CHANGELOG conflicts, both entries kept) and trimmed at a3a0ea2163: inventory.ts now has one runAdbShell(context, serial, words) helper instead of five repeated deviceShellArgv(..., ['-s', serial]) blocks, and the boundary lost a helper and a narration comment. I measured widening runCmd to readonly string[] to drop the three relayDeviceShellArgv(x, [...x]) copies; it ripples into ~30 override mocks across capture-kit, platform-apple, and platform-web, so those three lines stay. After rebase: typecheck, lint, format, layering, fallow, unit (1306 files), provider-integration (66 files) all green.

@thymikee
thymikee force-pushed the claude/shell-safe-boundary branch from f97c77e to a3a0ea2 Compare September 14, 2026 19:33
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-2611/

Built to branch gh-pages at 2026-09-14 19:33 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at a3a0ea2. Minting is now by identity in a WeakSet, which has no capacity, so the eviction failure from the last review cannot happen again, and every relay that rebuilds an argv carries the identity. Three problems remain.

Two long-lived process routes skip the guard. guardProviderDeviceShell at adb-provider-scope.ts#L320 wraps exec but leaves provider.spawn raw, and resolveScopedAndroidAdbBackgroundTransport also hands out scoped.provider.spawn unguarded. launchLocalAppLogCommand (src/platform-runtime-app-log-process.ts:173) runs runCmdBackground without assertHostDeviceShellRequest, and HarmonyOS hilog uses that route. Every argv there is minted today, so nothing is open now, but #2026 asks for the invariant by construction. Could spawn and the background transport go through the same assertion, and could launchLocalAppLogCommand call assertHostDeviceShellRequest first?

CHANGELOG.md#L10 tells custom adb executors and providers to use deviceShellArgv from @agent-device/kernel/device-shell, or runAndroidShell / runAdbShell / runHarmonyShell. The kernel package is private, no published entry exports these, and SDK providers never reach the guarded boundary. Could the entry describe the in-repo refusal instead, or should the funnel be exported if external callers need it?

inventory.ts#L243 adds a private runAdbShell(context, serial, words) beside the exported runAdbShell(adb, words, options) in the same package, plus a second spelling of the argv through adbShellArgv. Could the local helper get its own name, with adbShellArgv inlined at its one call site?

Is relayDeviceShellArgv(received, rebuilt) as strict as it needs to be? It re-mints any array without checking that the device-side tail matches received, so a relay could change the command and still pass. A relay that builds the array itself from received's subcommand onward would make that impossible and drop the three [...x] copies.

The live Android runs in the comments were on 1006591, before the rebase and the inventory change. Please add a live Android run on a3a0ea2 that covers device listing, open, snapshot and fill through the provider-scope relay.

Smoke Tests failed on iOS only: the Settings replay wait timed out once and passed on retry, and the fixture step wait text Agent Device Tester timed out after runner modal-probe timeouts. For an executable other than adb or hdc, the host port returns early and passes the same array as before, so the failure is likely unrelated. There are no conflicts.

The next step is guarding spawn and the app-log background route, fixing the CHANGELOG note, and the live Android run on a3a0ea2.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Typed device-shell execution boundary (ShellSafe) — mechanism + complete migration, one PR

1 participant