Files
callstack__agent-device/CONTRIBUTING.md
Michał Pierzchała 608bf7aa47 Harden the MCP surface: registry rug-pull fix, operator-only credentials/endpoints, device-shell argv gate, declared timeouts (#2023)
* chore(release): keep the version on main distinct from every published version

Registry scanners diff the repository's tool surface per version string, so a
released number left on main while main keeps changing is indistinguishable
from a republished ("rug-pull") version — two scans of the same version see
two different tool sets (AS-012).

- release:publish now runs release:mark-dev after npm publish, moving
  package.json and the synchronized server.json to the next patch with a
  -dev prerelease marker.
- release:prepare refuses to publish while the -dev marker is in place, so
  a forgotten version bump cannot ship a prerelease as latest.
- Mark the current tree 0.20.11-dev: main had been sitting on the published
  0.20.10 while the tool surface kept changing, which is the live finding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* fix(mcp): remove credential inputs from the model-writable tool surface

Every MCP tool advertised daemonAuthToken (and the Metro tools bearerToken)
as a free-form string the model writes. The model both reads untrusted app UI
text and picks tool arguments, so on-screen text steering it to set a token
was a prompt-injection exfiltration path. Credentials are operator-owned:

- the keys are omitted from every advertised tool schema (MCP and AI SDK,
  which share listCommandTools()),
- an explicit value is refused with env-var guidance instead of being
  forwarded (the retired-field posture: refuse, never silently drop),
- operator-sourced values are untouched — env/config defaults still merge,
  and the daemon and Metro clients keep their AGENT_DEVICE_DAEMON_AUTH_TOKEN
  / AGENT_DEVICE_METRO_BEARER_TOKEN fallbacks. CLI flags are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* fix(mcp): move operator endpoints and paths off the model-writable surface; declare timeouts

Follow-up to the credential removal: daemonBaseUrl and the Metro
proxyBaseUrl are the endpoints the env-resolved tokens are SENT to, so a
model-writable value redirects the operator's token to an arbitrary server —
same exfiltration path, one step removed. stateDir, cwd,
iosSimulatorDeviceSet, and the three iosXctest* paths select operator
infrastructure, never per-call work. All of them leave the advertised
MCP/AI-SDK tool schemas and are refused as explicit input with env/config
guidance; operator env/config defaults keep flowing exactly as before
(config-backed defaults still merge, and explicit input can no longer
override them). Dropping these shared properties also cuts tools/list
substantially.

Every tool description now also declares its enforced client timeout
envelope (90s default, 180s install, unbounded only for the streaming test
runner), sourced from the descriptor registry's timeout policy so the
declared number cannot drift from the enforced one (answers AS-011, which
read the undeclared envelope as "no timeout").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* feat(ci): inventory every dynamic value reaching a device shell

adb shell, adb exec-out, and hdc shell join their argv into one string the
device's sh evaluates, so any unquoted dynamic element is a potential argv
injection — the class of bug the audit found (and fixed) on input text and
cmd clipboard set text. Nothing enumerated the surface, so a new call site
could regress it silently.

scripts/shell-argv is an AST-based gate (oxc-parser, same as di-seams and
layering) keeping an exact inventory of every dynamic device-shell argv
element, keyed by (file, expression) with counts: 121 values today. A new
or grown entry fails CI until the author quotes it through shellQuoteIfNeeded
or records it with --update in the same PR, making "a new value now reaches
the device shell" a reviewable diff; a stale entry fails the other way so
the inventory always matches the code. Wired as the shell-argv gate in the
lint lane and registered in the check catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* fix(shell-argv): satisfy the fallow audit without suppressions

The Compatibility & Provenance lane's fallow audit flagged the new gate:
main was an unused export (only the self-run guard consumed it) and four
functions sat over the complexity thresholds. Restructure instead of
suppressing: the AST walk dispatches through a composite-child-field table,
the argv detection is hoisted out of the visitor, drift reporting moves into
helpers, and main is no longer exported. Behavior is unchanged — the model
tests pass as written and the regenerated inventory is byte-identical
(121 values).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* fix(test): close the port-reuse race in the unreachable-takeover test

Coverage (1) failed once in CI with the takeover notice missing while the
response still came from the fresh daemon — the exact signature of the
fresh fixture being handed the just-freed ephemeral port: the recorded
daemon becomes reachable and reusable (same version and signature), so the
takeover path is skipped. Bind the fresh fixture before acquiring and
freeing the unreachable port; with no bind after the close, the port can
never be reclaimed. Line-neutral so the size-ratchet pin holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* fix(mcp): enforce the advertised tool schema at a real admission boundary

P1 (reported by the PR author): hiding operator keys from tools/list did not
stop them reaching the command route. The router forwards raw tools/call
arguments verbatim and resolveMcpConfigDefaults reads them as CLI flags, so an
unadvertised `config`/`remoteConfig` key loaded an arbitrary file whose
daemonBaseUrl/daemonAuthToken then flowed to runCommand — a model-writable
redirect to an attacker endpoint with the operator's token. Reproduced:
{config: <path>} on `snapshot` put both values into the command input.

Replace the per-key operator refusal with a deny-by-default admission boundary
in the shared executor (the one path both the MCP router and the AI SDK adapter
use): every raw input key must appear in the tool's advertised schema, else it
is rejected with guidance BEFORE config/env resolution. This closes the config
loaders, the operator keys, and any unknown key at once, and makes the
advertised additionalProperties:false contract actually enforced. Operator
env/config defaults still resolve — they never arrive as tool input.

Retired keys (maxSize) are admitted so the command's own reader still answers
with migration guidance; they're exposed as metadata.retiredInputKeys for that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* revert(ci): remove the check:shell-argv inventory gate

The PR author correctly flagged that this gate is an inventory, not a
security invariant: --update lets any site self-approve a raw value, and the
literal-first array heuristic is blind to indirect argv (a variable-built
subcommand, or an argv assembled in a helper). Reproduced: adb(['shell',
'input','text',text]) is inventoried, but const s='shell';
adb([s,'input','text',text]) yields no finding. Shipping it security-framed
gives false assurance.

Remove it. The sound fix — a typed device-shell execution boundary where a
raw string cannot reach adb/hdc shell without being quoted or explicitly
marked — is a ~188-site cross-package migration on device execution paths,
scoped to a dedicated follow-up PR. The two known-dangerous sites (input
text, cmd clipboard set text) already quote through shellQuoteIfNeeded on
main, so no regression. This keeps the PR focused on the MCP tool surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-25 15:19:07 +02:00

8.6 KiB

Contributing

Thanks for helping improve agent-device. This guide is the shortest path from a fresh checkout to a reviewable change. Detailed testing and device procedures live in the linked focused guides.

Set up the repository

Requirements:

  • Node.js 22 or newer
  • pnpm at the version pinned in package.json
  • Android SDK tools (adb) for Android work
  • Xcode (simctl/devicectl) for Apple-platform work
pnpm install
pnpm build

package.json's packageManager field is the source of truth for pnpm, and CI rejects a different version. Node distributions that include Corepack can activate it with corepack enable pnpm. Newer Node distributions may not bundle Corepack; in that case, install the pinned pnpm version using your Node version manager or the pnpm installation guide, then confirm it with pnpm --version before installing dependencies.

The root install does not install the much larger Expo test-app dependency graph. If your change touches examples/test-app, install it separately:

pnpm test-app:install
pnpm test-app:typecheck

Build the surface you changed

pnpm build compiles the TypeScript CLI and library. If a running development daemon must pick up that build, use pnpm rebuild:cli; it builds and then stops the worktree-scoped daemon.

Build only the Apple runner target you changed:

pnpm build:xcuitest:ios
pnpm build:xcuitest:macos
pnpm build:xcuitest:tvos
pnpm build:xcuitest:visionos

Append :clean to any platform build when DerivedData may be stale, for example pnpm build:xcuitest:macos:clean. pnpm build:xcuitest remains the shared iOS-and-macOS gate for changes that affect both runners; it is not an all-platform build.

Android and macOS helper builds remain separate because they require their native toolchains:

pnpm build:android
pnpm build:macos-helper

There is intentionally no catch-all development build. Native toolchains are expensive and independent, so agents and contributors should run the command for the surface they changed. Use pnpm build:macos-helper:clean if a Swift cache was created in another worktree.

Prepare the npm package

pnpm publish and package-manager pack commands run prepack, which first checks synchronized MCP metadata and then runs pnpm package:npm. This is the one completeness-oriented aggregate: it builds the TypeScript distribution and all four Apple runner targets, clean-builds the macOS helper, packages the Apple runner source, and rebuilds both Android helper APKs. Any failed build stops packaging. It deliberately does not stop the worktree's development daemon; use pnpm rebuild:cli when a running daemon needs to pick up a new TypeScript build.

pnpm package:npm is a release guard, not a routine development command. Use the specific commands above while iterating.

The version on main never equals a published version

release:publish runs release:mark-dev right after npm publish, moving package.json (and the synchronized server.json) to the next patch with a -dev prerelease marker (for example 0.20.11-dev). Commit that bump as part of the release. The invariant it protects: MCP registry scanners diff the repository's tool surface per version string, so a released number left on main while main keeps changing is indistinguishable from a republished ("rug-pull") version. release:prepare enforces the inverse direction and refuses to publish while the -dev marker is still in place — set the real release version first (for example npm version patch, which strips the prerelease marker), commit, then publish.

Released-surface baselines roll forward on publish

Compatibility gates baseline against the last released tag, not against main, so publishing is what advances them — there is no separate baseline-refresh step and no regenerate command. Tagging a release makes that commit's test/wire-compat/ledger.json the new baseline for pnpm check:daemon-wire-compat, and its .ad corpus tags the new ceiling for pnpm check:replay-compat. The practical consequence for a normal PR: wire churn within an unreleased branch is free, and only the net change since the last publish has to carry a DAEMON_RPC_PROTOCOL_VERSION bump or a compatibleChanges acknowledgment. After a release that bumped the protocol version, the acknowledgments accumulated against the previous one no longer match any current digest and are dropped — git history keeps the audit trail.

Validate a change

Use the smallest trustworthy loop while editing:

pnpm check:quick             # lint + TypeScript
pnpm test:maestro-compat     # example of a focused family suite
pnpm exec vitest run path/to/file.test.ts

Before pushing a normal code change, let the repository derive the required gates:

pnpm check:affected --run

The selector combines the committed diff with staged, unstaged, and untracked files. Unknown, workflow, lockfile, and selector-owning changes fail open to the full local set. It reports device/toolchain checks that remain GitHub-authoritative instead of trying to run them implicitly.

For broad refactors or when explicitly requested, run the deterministic core aggregate:

pnpm check

pnpm check covers formatting, lint, typechecking, layering, dependency-graph parity, production exports, MCP metadata, the distributable build, bundle ownership, Fallow, unit tests, and local smoke tests. It is intentionally not a simulation of every CI job: coverage, provider integration, history-backed compatibility, specialized toolchains, and live device/browser lanes remain separate. GitHub CI is authoritative.

Useful direct entry points:

  • pnpm test or pnpm test:unit — root unit projects
  • pnpm test:coverage — coverage plus coverage-only projects
  • pnpm test:integration — Node and provider-backed integration suites
  • pnpm perf --platform ios or pnpm perf --platform android — device performance harness
  • pnpm check:fallow --base origin/main — changed-code quality gate
  • pnpm fallow:all — full-tree audit, including grandfathered baseline findings
  • pnpm fallow:baseline — intentionally regenerate both reviewed Fallow baselines

See docs/agents/testing.md for gate ownership, shared test utilities, mutation/fuzz lanes, contention policy, and test-speed rules. For real devices, follow docs/agents/device-verification.md; a fixture-backed test does not prove that a native path was active.

Test app and Maestro compatibility

The Expo fixture app owns its setup, simulator/device, Metro, replay, and Maestro instructions in examples/test-app/README.md.

The stable compatibility entry points are:

pnpm test:maestro-compat
pnpm maestro:conformance
pnpm test-app:maestro:ios
pnpm test-app:maestro:android

The first two are deterministic and device-free. The test-app suites need the app, Metro when applicable, and a real simulator or emulator.

Contribution guidelines

  • Keep dependencies minimal and prefer built-in Node APIs.
  • Preserve the CLI's compact, agent-friendly JSON output.
  • Open and close sessions explicitly in tests and manual verification.
  • Add or adjust integration coverage when introducing a command or changing a wire response.
  • Run the focused gate that owns the behavior; do not replace missing coverage with a broad, assertion-free test.

Conservative code comments

When code deliberately chooses a slower or more conservative path, leave a short comment at the decision site naming the prevented failure and the condition for revisiting the choice. Use the grep-able CONSERVATIVE: prefix when the decision is expected to outlive the current change.

// CONSERVATIVE: Preserve external runner artifacts because the checkout does not own their cache
// root. Revisit only if external artifacts get an ownership marker that makes cleanup safe.

Dependency updates

Renovate proposes weekly lockfile maintenance, grouped development-dependency updates, individual runtime-dependency updates, and GitHub Action digest bumps. Automerge is disabled: dependency PRs need green CI and human review.

Read the release notes, inspect the affected-check plan, and treat a green update as a merge candidate rather than an automatic merge:

pnpm check:affected --run

Issues

Issue labels describe workflow state, not ownership. See docs/agents/triage-labels.md.

When reporting a problem, include the OS and Node version, relevant Xcode or Android SDK versions, and the exact command and output.