feat: add Frida instrumentation support (#1359)

* feat: add Frida instrumentation support

* fix: resolve Frida CI checks

* test: cover Frida contract validation branches

* docs: link Frida instrumentation guide

* fix(frida): handle cancellation cleanup safely

* fix(frida): address PR review findings

* fix(frida): preserve resume and message observations

* fix(ci): use canonical registry for rolldown

* fix(frida): declare foreign code effects

* fix(frida): retain failed startup resources for cleanup retries

Keep failed startup session and spawn handles owned until detach, resume,
and remote lease cleanup complete. Serialize retries and release each
remote reference once. Await CLI provider cleanup while preserving the
primary failure and any completed observations.

Add regressions for retained startup resources, concurrent cleanup,
shared remote leases, and CLI cleanup outcomes.

Validation: 41 focused tests pass on the PR head and its latest-main
integration; serial static and docs checks pass. Real Linux Frida 17.23.2
workflows and the native spawn/controlled-failure cleanup probe pass.

Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)

---------

Co-authored-by: N0zoM1z0 <1705004398@qq.com>
This commit is contained in:
Daniel Gonçalves Araujo 2026-10-12 11:01:58 -03:00 committed by GitHub
parent 0b7145361e
commit 4a4ae9b2b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 5845 additions and 148 deletions

View File

@ -0,0 +1,13 @@
# Frida instrumentation
REA exposes Frida device and process discovery, attach or spawn sessions, and caller-authored JavaScript instrumentation through the CLI and MCP. The Node bindings are an optional package dependency and load only when a Frida operation runs. REA does not install Frida server on a target.
For remote devices, pass a host with an optional port (`host[:port]`; bracket IPv6 addresses) and any token, certificate, origin, or keepalive options on that operation. The keepalive interval is measured in seconds, matching Frida Core 17.23.2. Authentication values are held only for the active call or session and are excluded from Evidence. Do not embed credentials in the endpoint address.
MCP exposes `list_frida_devices`, `list_frida_processes`, `start_frida_session`, `load_frida_script`, `resume_frida_session`, `unload_frida_script`, `frida_session_status`, and `close_frida_session`. A spawned process starts paused so scripts can load first. Closing unloads session scripts and detaches. If a spawn remains paused, REA resumes it only after script cleanup or session detach confirms the instrumentation is released; if both cleanup paths fail, the target stays paused and the cleanup error is retained. REA never kills the target. MCP sessions end with the server process. The one-shot CLI command `instrument-with-frida` accepts one inline script or script file, observes messages for a bounded duration, and returns Evidence with the source hash and captured messages.
Failed startup cleanup retains acquired Frida handles, including a paused spawn that has not attached, for later cleanup retries. Its remote connection stays leased until session detach and target resume complete. CLI commands await provider cleanup before returning; MCP retains these resources for cleanup when its server closes. A failed cleanup remains reported rather than treating an unregistered session as fully released.
Frida scripts execute inside the selected target with that process's memory, filesystem, and network authority. Message capture is bounded to 16 MiB and reports truncation. Script source is not stored in Evidence; its selected path and SHA-256 digest are. Evidence retains non-authentication target selection such as device or endpoint, mode, program or PID, and spawn arguments. Authentication values remain excluded. Results report the observed session and messages, while unsupported platform metadata remains unknown. A request cancelled before a Frida operation starts has no provider effects. If cancellation arrives during a native Frida call, REA waits for that call to return, then closes any session it created and returns the observed session as a partial observation. Cancellation Evidence separately reports whether a target resume was observed, whether instrumentation cleanup completed, and that final target liveness remains unknown. REA does not kill the target. Remote discovery retains its observations and reports a separate cleanup error if the temporary connection cannot be removed.
The [Frida Core API](https://frida.re/docs/frida-core-api/) documents device management, sessions, and script loading; the [JavaScript API](https://frida.re/docs/javascript-api/) documents target-side instrumentation. [Luma](https://luma.frida.re/) is Frida's standalone GUI; REA does not automate or control it. `npm run verify:frida` requires a Linux host with the optional Frida binding and uses a short-lived local Node.js fixture created by the verifier. Add `-- --address HOST:PORT --pid PID` to run the separate remote attach lane against a caller-selected target. Mock tests do not establish real device/server compatibility.

View File

@ -43,4 +43,6 @@ npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --
See [installation and setup](./installation.md) for requirements and configuration.
For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md).
![REA inspecting a native binary in Hopper](./assets/rea-hopper-analysis.png)

View File

@ -1414,6 +1414,18 @@ npm run evidence:check
The records preserve unsupported and unverified coverage as explicit unknowns.
Run the matching real-tool prerequisites described in this guide.
## Frida instrumentation
`npm run verify:frida` builds REA, spawns a short-lived local Node.js fixture,
loads a harmless marker script before resuming it, captures the message, and
detaches. This lane requires Linux on the REA host and the optional Frida
binding; it does not need a Frida server or a caller-selected process. To run
the separate remote attach lane, pass `-- --address HOST:PORT --pid PID` for an
explicitly selected process. Optional remote flags are `--token`,
`--certificate`, `--origin`, and `--keepalive-interval` (seconds); authentication values
are not written to files or included in the report. Mock tests do not establish
real device/server compatibility.
### Offline WASM artifacts
`npm run verify:wasm:artifact -- --require-tools` uses an absolute

View File

@ -7,6 +7,7 @@
"src/contracts/errorSchemas.ts",
"src/evaluation/AgentEvaluationFixtures.ts",
"src/application/RealHopperAssertions.ts",
"scripts/verify/fridaBindingsCompatibility.ts",
"scripts/verify/termux/browser.mjs",
"scripts/verify-real-ghidra-noreturn.mjs",
"tests/processOwnershipRunner.ts"

450
package-lock.json generated
View File

@ -73,6 +73,9 @@
},
"engines": {
"node": "^22.19.0 || ^24.11.0 || >=26.0.0"
},
"optionalDependencies": {
"frida": "17.23.2"
}
},
"node_modules/@algolia/abtesting": {
@ -742,9 +745,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -903,9 +903,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -923,9 +920,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -943,9 +937,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -963,9 +954,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -983,9 +971,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1003,9 +988,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1023,9 +1005,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1043,9 +1022,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1239,9 +1215,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1256,9 +1229,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1273,9 +1243,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1290,9 +1257,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1307,9 +1271,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1324,9 +1285,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1341,9 +1299,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1358,9 +1313,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1878,9 +1830,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1898,9 +1847,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1918,9 +1864,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1938,9 +1881,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1958,9 +1898,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1978,9 +1915,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1998,9 +1932,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2018,9 +1949,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2214,9 +2142,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2234,9 +2159,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2254,9 +2176,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2274,9 +2193,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2294,9 +2210,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2314,9 +2227,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2476,9 +2386,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2493,9 +2400,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2510,9 +2414,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2527,9 +2428,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2544,9 +2442,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2561,9 +2456,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2578,9 +2470,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2595,9 +2484,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2612,9 +2498,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2629,9 +2512,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2646,9 +2526,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2663,9 +2540,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2680,9 +2554,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -4142,6 +4013,16 @@
],
"license": "MIT"
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
@ -4152,6 +4033,58 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"optional": true,
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/bl/node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true,
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/bl/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"optional": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
@ -4290,6 +4223,13 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC",
"optional": true
},
"node_modules/clean-git-ref": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz",
@ -4418,6 +4358,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@ -4449,9 +4399,8 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8"
}
@ -4513,6 +4462,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"optional": true,
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@ -4644,6 +4603,16 @@
"node": ">=22"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"optional": true,
"engines": {
"node": ">=6"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
@ -4751,6 +4720,13 @@
}
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT",
"optional": true
},
"node_modules/focus-trap": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz",
@ -4793,6 +4769,29 @@
"node": ">=18.3.0"
}
},
"node_modules/frida": {
"version": "17.23.2",
"resolved": "https://registry.npmjs.org/frida/-/frida-17.23.2.tgz",
"integrity": "sha512-gRCmmTUFdEILyHRN1b66erRoXafwsfWTqXyoBBqkSCFX0Ue7Jhvs/KhA3JO0XrgITNlPd91QAGsFvWzh0mhVgg==",
"hasInstallScript": true,
"license": "LGPL-2.0 WITH WxWindows-exception-3.1",
"optional": true,
"dependencies": {
"bindings": "^1.5.0",
"minimatch": "^10.0.1",
"prebuild-install": "^7.1.3"
},
"engines": {
"node": ">=16"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT",
"optional": true
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -4880,6 +4879,13 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT",
"optional": true
},
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
@ -5119,6 +5125,13 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC",
"optional": true
},
"node_modules/is-callable": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
@ -5555,9 +5568,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -5580,9 +5590,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -5605,9 +5612,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -5630,9 +5634,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -6070,6 +6071,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT",
"optional": true
},
"node_modules/nanoid": {
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
@ -6089,6 +6097,26 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT",
"optional": true
},
"node_modules/node-abi": {
"version": "3.98.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.98.0.tgz",
"integrity": "sha512-Llh8noNw7s15m+rmIrZnZQbe4gLyasL9loT74rC92PkmwWKeEedKvc7o6pjp8B3MhrYT/Cl846NOYZbz1wmddw==",
"license": "MIT",
"optional": true,
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/obug": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
@ -6554,6 +6582,34 @@
}
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
@ -6590,6 +6646,17 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"optional": true,
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/pure-rand": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
@ -6613,6 +6680,32 @@
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"optional": true,
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/rc/node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
@ -7178,6 +7271,51 @@
"dev": true,
"license": "MIT"
},
"node_modules/tar-fs": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"license": "MIT",
"optional": true,
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tar-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"optional": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/thread-stream": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
@ -7286,6 +7424,19 @@
"license": "0BSD",
"optional": true
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/turbo": {
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/turbo/-/turbo-2.11.7.tgz",
@ -7458,6 +7609,13 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT",
"optional": true
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",

View File

@ -181,6 +181,8 @@
"verify:ghidra:cross-format": "npm run build:cached && node scripts/verify-real-ghidra.mjs --cross-format",
"verify:ghidra:windows": "npm run build:cached && npm run build:ghidra-windows-fixture && node scripts/verify-real-ghidra-windows.mjs",
"verify:browser": "npm run build:cached && node scripts/verify-real-browser.mjs",
"verify:frida": "npm run build:cached && node scripts/verify-frida.mjs",
"verify:frida:bindings": "tsc --noEmit -p tsconfig.frida-verification.json",
"verify:browser:dom": "node scripts/verify-browser-dom-destinations.mjs",
"verify:browser:network": "npm run build:cached && node scripts/verify-browser-network.mjs",
"verify:electron": "npm run build:cached && node scripts/verify-real-electron.mjs",
@ -341,5 +343,11 @@
"typescript-termux": "npm:typescript@5.9.3",
"vitepress": "1.6.4",
"vitest": "5.0.3"
},
"optionalDependencies": {
"frida": "17.23.2"
},
"allowScripts": {
"frida@17.23.2": true
}
}

View File

@ -49,6 +49,11 @@ export const toolFamilyCatalog = (sources) => {
surface: "android-provider",
contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS,
},
{
id: "frida",
surface: "instrumentation-provider",
contracts: sources.fridaContracts.FRIDA_TOOL_CONTRACTS,
},
{
id: "apktool",
surface: "apktool-provider",
@ -145,6 +150,10 @@ export const providerCatalog = (sources) => {
activeContracts,
} = electronContractSlices(sources);
return [
{
identity: sources.fridaProvider.FRIDA_PROVIDER_IDENTITY,
contracts: sources.fridaContracts.FRIDA_TOOL_CONTRACTS,
},
{
identity: sources.hopperProvider.HOPPER_PROVIDER_IDENTITY,
contracts: declaredContracts(

View File

@ -43,6 +43,8 @@ const SOURCE_PATHS = {
sessionContracts: "dist/contracts/sessionToolContracts.js",
binaryDiagnosticsContracts:
"dist/contracts/native/binaryDiagnosticsToolContracts.js",
fridaContracts: "dist/contracts/fridaToolContracts.js",
fridaProvider: "dist/frida/FridaInstrumentationManager.js",
pwntoolsProvider: "dist/native/pwntools/PwntoolsRelease.js",
nativeContracts: "dist/contracts/native/nativeToolContracts.js",
wasmContracts: "dist/contracts/wasm/wasmToolContracts.js",

198
scripts/verify-frida.mjs Normal file
View File

@ -0,0 +1,198 @@
import { basename } from "node:path";
import { FridaInstrumentationService } from "../dist/application/frida/FridaInstrumentationService.js";
import { FridaInstrumentationManager } from "../dist/frida/FridaInstrumentationManager.js";
import { isValidFridaRemoteAddress } from "../dist/contracts/fridaRemoteAddress.js";
const options = parseArguments(process.argv.slice(2));
if (options.help) {
process.stdout.write(
"Usage: npm run verify:frida [-- --address HOST:PORT --pid PID [remote auth flags]]\n" +
"Without flags, verifies a local Node.js process created by this script.\n",
);
process.exit(0);
}
const manager = new FridaInstrumentationManager();
const service = new FridaInstrumentationService(manager);
const remote =
options.address === undefined
? undefined
: {
address: options.address,
...(options.token === undefined ? {} : { token: options.token }),
...(options.certificate === undefined
? {}
: { certificate: options.certificate }),
...(options.origin === undefined ? {} : { origin: options.origin }),
...(options.keepaliveInterval === undefined
? {}
: { keepaliveInterval: options.keepaliveInterval }),
};
try {
let targetProcess;
let input;
if (remote === undefined) {
input = {
mode: "spawn",
deviceId: "local",
program: process.execPath,
argv: ["-e", "setTimeout(() => {}, 1500)"],
source: {
sourceKind: "inline",
source: "send({ rea_frida_verification: 'ok', pid: Process.id });",
},
durationMs: 500,
};
targetProcess = { pid: undefined, name: basename(process.execPath) };
} else {
const inventory = await service.listProcesses({ remote });
if (!inventory.ok) throw new Error(inventory.error.message);
targetProcess = inventory.value.processes.find(
({ pid }) => pid === options.pid,
);
if (targetProcess === undefined)
throw new Error(
`Selected PID ${String(options.pid)} was not observed remotely`,
);
input = {
mode: "attach",
remote,
pid: options.pid,
source: {
sourceKind: "inline",
source: "send({ rea_frida_verification: 'ok', pid: Process.id });",
},
durationMs: 500,
};
}
const result = await service.instrument(input);
if (!result.ok) throw new Error(result.error.message);
const observed = result.value.evidence.normalized_result;
if (
typeof observed !== "object" ||
observed === null ||
Array.isArray(observed) ||
!Array.isArray(observed.messages)
)
throw new Error("Frida returned no message observation");
const messageObserved = observed.messages.some(
(message) =>
typeof message === "object" &&
message !== null &&
!Array.isArray(message) &&
message.type === "send" &&
typeof message.payload === "object" &&
message.payload !== null &&
!Array.isArray(message.payload) &&
message.payload.rea_frida_verification === "ok" &&
message.payload.pid === observed.pid,
);
if (!messageObserved)
throw new Error("Selected target did not return the Frida marker");
const report = {
ok: result.value.cleanupError === null,
provider: "frida",
host_platform: process.platform,
target_mode: remote === undefined ? "local_spawn" : "remote_attach",
target_pid: observed.pid,
target_name: targetProcess.name,
...(remote === undefined ? {} : { remote_target_observed: true }),
script_message_observed: true,
cleanup_error: result.value.cleanupError,
};
process.stdout.write(`${JSON.stringify(report)}\n`);
if (!report.ok) process.exitCode = 1;
} catch (cause) {
process.stderr.write(
`Frida real-provider verification failed: ${cause instanceof Error ? cause.message : String(cause)}\n`,
);
process.exitCode = 1;
} finally {
await manager.closeAll().catch(() => undefined);
}
function parseArguments(args) {
if (args.length === 1 && args[0] === "--help") return { help: true };
const values = parseOptionValues(args);
const address = values.get("--address");
const pidValue = values.get("--pid");
const pid = parseRemotePid(address, pidValue);
if (address === undefined) assertNoRemoteOptions(values);
const keepaliveInterval = parseKeepalive(values.get("--keepalive-interval"));
return {
help: false,
...(address === undefined ? {} : { address }),
...(pid === undefined ? {} : { pid }),
...(values.has("--token") ? { token: values.get("--token") } : {}),
...(values.has("--certificate")
? { certificate: values.get("--certificate") }
: {}),
...(values.has("--origin") ? { origin: values.get("--origin") } : {}),
...(keepaliveInterval === undefined ? {} : { keepaliveInterval }),
};
}
function parseOptionValues(args) {
const values = new Map();
const valueFlags = new Set([
"--address",
"--pid",
"--token",
"--certificate",
"--origin",
"--keepalive-interval",
]);
for (let index = 0; index < args.length; index += 1) {
const flag = args[index];
const value = args[index + 1];
if (
typeof flag !== "string" ||
!valueFlags.has(flag) ||
value === undefined ||
values.has(flag)
)
throw new Error(
"Expected no arguments for local-fixture verification, or --address HOST:PORT --pid PID and optional remote auth flags",
);
values.set(flag, value);
index += 1;
}
return values;
}
function parseRemotePid(address, pidValue) {
if ((address === undefined) !== (pidValue === undefined))
throw new Error("Remote verification requires both --address and --pid");
if (address !== undefined && !isValidFridaRemoteAddress(address))
throw new Error(
"--address must be a host[:port] without credentials, path, query, or fragment",
);
const pid = pidValue === undefined ? undefined : Number(pidValue);
if (pid !== undefined && (!Number.isSafeInteger(pid) || pid <= 0))
throw new Error("--pid must be a positive process identifier");
return pid;
}
function assertNoRemoteOptions(values) {
for (const flag of [
"--token",
"--certificate",
"--origin",
"--keepalive-interval",
])
if (values.has(flag))
throw new Error("Remote connection options require --address and --pid");
}
function parseKeepalive(value) {
const interval = value === undefined ? undefined : Number(value);
if (
interval !== undefined &&
(!Number.isSafeInteger(interval) || interval <= 0)
)
throw new Error("--keepalive-interval must be a positive integer");
return interval;
}

View File

@ -0,0 +1,28 @@
import type {
Device as VendorDevice,
DeviceManager as VendorDeviceManager,
Script as VendorScript,
Session as VendorSession,
} from "../../node_modules/frida/build/src/frida_binding.js";
import type { Device, DeviceManager, Script, Session } from "frida";
type Assert<Condition extends true> = Condition;
type VendorDeviceManagerFitsShim = Assert<
VendorDeviceManager extends DeviceManager ? true : false
>;
type VendorDeviceFitsShim = Assert<VendorDevice extends Device ? true : false>;
type VendorSessionFitsShim = Assert<
VendorSession extends Session ? true : false
>;
type VendorScriptFitsShim = Assert<VendorScript extends Script ? true : false>;
// These aliases intentionally fail when the pinned Frida API narrows incompatibly.
const compatibility: readonly [
VendorDeviceManagerFitsShim,
VendorDeviceFitsShim,
VendorSessionFitsShim,
VendorScriptFitsShim,
] = [true, true, true, true];
void compatibility;

View File

@ -22,6 +22,7 @@ import type { JsonValue } from "../domain/jsonValue.js";
import { isMachOFormat } from "../domain/binaryTargetTypes.js";
import type { ProviderAvailability } from "./AnalysisProvider.js";
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import { FRIDA_TOOL_CONTRACTS } from "../contracts/fridaToolContracts.js";
import {
clientRequirementsFor,
NO_CLIENT_FEATURES,
@ -131,6 +132,9 @@ const ACTIVE_TARGET_KINDS: ReadonlySet<ToolKind> = new Set([
"native-provider",
"artifact-provider",
]);
const FRIDA_OPERATION_NAMES: ReadonlySet<string> = new Set(
FRIDA_TOOL_CONTRACTS.map(({ name }) => name),
);
/** Native contracts defined only for an active Mach-O executable. */
const MACHO_TARGET_OPERATIONS: ReadonlySet<string> = new Set([
@ -316,6 +320,7 @@ const optionalProviderFailureFor = ({
if (kind === "electron-provider") return failures?.electronObservation;
if (kind === "runtime-provider")
return failures?.javascriptRuntimeObservation;
if (FRIDA_OPERATION_NAMES.has(name)) return failures?.fridaInstrumentation;
return undefined;
};
@ -533,6 +538,7 @@ const targetAvailability = ({
targetFormat,
targetOpen,
}: AvailabilityContext): Availability | null => {
if (FRIDA_OPERATION_NAMES.has(name)) return null;
if (!targetOpen && ACTIVE_TARGET_KINDS.has(kind))
return {
reason: "target_required",

View File

@ -22,7 +22,10 @@ export interface OptionalProviderLoadFailure {
export type OptionalProviderLoadFailures = Readonly<
Partial<
Record<keyof OptionalObservationProviders, OptionalProviderLoadFailure>
Record<
keyof OptionalObservationProviders | "fridaInstrumentation",
OptionalProviderLoadFailure
>
>
>;

View File

@ -0,0 +1,988 @@
import { describe, expect, it } from "vitest";
import type {
Device,
DeviceManager,
FridaMessage,
Process,
Script,
Session,
Signal,
} from "frida";
import { FridaInstrumentationManager } from "../../frida/FridaInstrumentationManager.js";
const signal = <Handler extends (...args: never[]) => void>() => {
let connected: Handler | undefined;
const value: Signal<Handler> & {
emit: (...args: Parameters<Handler>) => void;
} = {
connect: (handler) => {
connected = handler;
},
disconnect: (handler) => {
if (connected === handler) connected = undefined;
},
emit: (...args) => connected?.(...args),
};
return value;
};
class FakeScript implements Script {
readonly message =
signal<(message: FridaMessage, data: Buffer | null) => void>();
isDestroyed = false;
loadFailure: Error | undefined;
unloadFailureCount = 0;
unloadCalls = 0;
constructor(
readonly payload: unknown = { event: "loaded" },
readonly beforeLoad: () => Promise<void> = async () => {},
readonly description = "script loaded",
readonly stack = "",
) {}
async load(): Promise<void> {
await this.beforeLoad();
if (this.loadFailure !== undefined) throw this.loadFailure;
this.message.emit(
{
type: "send",
payload: this.payload,
description: this.description,
stack: this.stack,
},
null,
);
}
async unload(): Promise<void> {
this.unloadCalls += 1;
if (this.unloadFailureCount > 0) {
this.unloadFailureCount -= 1;
throw new Error("script unload failed");
}
if (this.isDestroyed) throw new Error("Script is destroyed");
this.isDestroyed = true;
}
}
class FakeSession implements Session {
readonly detached = signal<() => void>();
readonly scripts: FakeScript[] = [];
nextPayload: unknown = { event: "loaded" };
nextDescription = "script loaded";
nextStack = "";
nextLoadFailure: Error | undefined;
nextUnloadFailureCount = 0;
detachFailureCount = 0;
scriptLoadGate: Promise<void> | undefined;
onScriptLoadStarted: (() => void) | undefined;
private detachedState = false;
readonly pid = 512;
isDetached(): boolean {
return this.detachedState;
}
async createScript(): Promise<Script> {
const script = new FakeScript(
this.nextPayload,
async () => {
this.onScriptLoadStarted?.();
await this.scriptLoadGate;
},
this.nextDescription,
this.nextStack,
);
script.loadFailure = this.nextLoadFailure;
script.unloadFailureCount = this.nextUnloadFailureCount;
this.nextPayload = { event: "loaded" };
this.nextDescription = "script loaded";
this.nextStack = "";
this.nextLoadFailure = undefined;
this.nextUnloadFailureCount = 0;
this.scripts.push(script);
return script;
}
async detach(): Promise<void> {
if (this.detachFailureCount > 0) {
this.detachFailureCount -= 1;
throw new Error("session detach failed");
}
for (const script of this.scripts) script.isDestroyed = true;
this.markDetached();
}
markDetached(): void {
this.detachedState = true;
this.detached.emit();
}
markDetachedWithoutSignal(): void {
this.detachedState = true;
}
}
class FakeDevice implements Device {
readonly id = "local";
readonly name = "Local System";
readonly type = "local";
readonly process = { pid: 512, name: "fixture" } satisfies Process;
readonly session = new FakeSession();
resumed: number[] = [];
resumeFailureCount = 0;
readonly spawned: string[] = [];
async enumerateProcesses(): Promise<Process[]> {
return [this.process];
}
async getProcessByPid(): Promise<Process> {
return this.process;
}
async spawn(program: string): Promise<number> {
this.spawned.push(program);
return this.process.pid;
}
async resume(pid: number): Promise<void> {
if (this.resumeFailureCount > 0) {
this.resumeFailureCount -= 1;
throw new Error("target resume failed");
}
this.resumed.push(pid);
}
async attach(): Promise<Session> {
return this.session;
}
}
class FakeDeviceManager implements DeviceManager {
readonly device = new FakeDevice();
removedRemote: string[] = [];
removeRemoteFailureCount = 0;
removeRemoteFailureMessage = "remote device removal failed";
async enumerateDevices(): Promise<Device[]> {
return [this.device];
}
async getDeviceById(): Promise<Device> {
return this.device;
}
async addRemoteDevice(): Promise<Device> {
return this.device;
}
async removeRemoteDevice(address: string): Promise<void> {
if (this.removeRemoteFailureCount > 0) {
this.removeRemoteFailureCount -= 1;
throw new Error(this.removeRemoteFailureMessage);
}
this.removedRemote.push(address);
}
}
const startLocalAttachedSession = async (
bindings = new FakeDeviceManager(),
) => {
const manager = new FridaInstrumentationManager({ deviceManager: bindings });
const started = await manager.startSession({
mode: "attach",
deviceId: "local",
pid: 512,
});
return { bindings, manager, started };
};
// eslint-disable-next-line max-lines-per-function -- related manager lifecycle cases share the same focused fake provider.
describe("FridaInstrumentationManager", () => {
it("retains a failed startup's spawn and remote lease until cleanup succeeds", async () => {
const bindings = new FakeDeviceManager();
bindings.device.attach = async () => {
throw new Error("attach failed after spawn");
};
bindings.device.resumeFailureCount = 2;
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "spawn",
remote: { address: "127.0.0.1:27042" },
program: "/tmp/fixture",
});
if (started.ok) throw new Error("Attach unexpectedly succeeded");
expect(started.error.cleanupIncomplete).toBe(true);
expect(bindings.device.resumed).toEqual([]);
expect(bindings.removedRemote).toEqual([]);
await expect(manager.closeAll()).rejects.toThrow("cleanup was incomplete");
expect(bindings.device.resumed).toEqual([]);
expect(bindings.removedRemote).toEqual([]);
// A lookup could now select a different device with the same PID. Cleanup
// must use the original provider handle, not select the target again.
const replacement = new FakeDevice();
bindings.getDeviceById = async () => replacement;
bindings.addRemoteDevice = async () => replacement;
await Promise.all([manager.closeAll(), manager.closeAll()]);
expect(bindings.device.resumed).toEqual([512]);
expect(replacement.resumed).toEqual([]);
expect(bindings.removedRemote).toEqual(["127.0.0.1:27042"]);
await manager.closeAll();
expect(bindings.device.resumed).toEqual([512]);
expect(bindings.removedRemote).toEqual(["127.0.0.1:27042"]);
});
it("retains an acquired session when startup fails before registration", async () => {
const bindings = new FakeDeviceManager();
bindings.device.session.detached.connect = () => {
throw new Error("detached signal registration failed");
};
bindings.device.session.detachFailureCount = 1;
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
});
if (started.ok)
throw new Error("Signal registration unexpectedly succeeded");
expect(started.error.cleanupIncomplete).toBe(true);
expect(bindings.device.session.isDetached()).toBe(false);
expect(bindings.device.resumed).toEqual([]);
await manager.closeAll();
expect(bindings.device.session.isDetached()).toBe(true);
expect(bindings.device.resumed).toEqual([512]);
});
it("does not release another session's remote lease when retrying failed startup removal", async () => {
const bindings = new FakeDeviceManager();
bindings.device.attach = async () => {
throw new Error("attach failed");
};
bindings.removeRemoteFailureCount = 1;
const remote = { address: "127.0.0.1:27042" };
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const failed = await manager.startSession({
mode: "spawn",
remote,
program: "/tmp/fixture",
});
if (failed.ok) throw new Error("Attach unexpectedly succeeded");
expect(failed.error.cleanupIncomplete).toBe(true);
expect(bindings.device.resumed).toEqual([512]);
bindings.device.attach = async () => bindings.device.session;
const active = await manager.startSession({
mode: "attach",
remote,
pid: 512,
});
if (!active.ok) throw active.error;
const removedWhileSessionAttached: boolean[] = [];
const remove = bindings.removeRemoteDevice.bind(bindings);
bindings.removeRemoteDevice = async (address) => {
removedWhileSessionAttached.push(!bindings.device.session.isDetached());
await remove(address);
};
await manager.closeAll();
expect(removedWhileSessionAttached).toEqual([false]);
expect(bindings.removedRemote).toEqual([remote.address]);
expect(bindings.device.resumed).toEqual([512]);
});
it("loads instrumentation before resuming a spawned target", async () => {
const bindings = new FakeDeviceManager();
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
});
expect(started.ok).toBe(true);
if (!started.ok) return;
expect(started.value.state).toBe("paused");
expect(bindings.device.resumed).toEqual([]);
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send({ event: 'loaded' });",
});
expect(loaded.ok).toBe(true);
if (!loaded.ok) return;
expect(loaded.value.messages).toContainEqual(
expect.objectContaining({
type: "send",
payload: { event: "loaded" },
}),
);
expect(loaded.value.sourceSha256).toMatch(/^[a-f0-9]{64}$/u);
expect(bindings.device.resumed).toEqual([]);
const resumed = await manager.resumeSession(started.value.sessionId);
expect(resumed.ok).toBe(true);
expect(bindings.device.resumed).toEqual([512]);
expect(manager.status(started.value.sessionId)?.targetResumeObserved).toBe(
true,
);
expect(manager.status(started.value.sessionId)?.state).toBe("running");
await manager.closeAll();
expect(bindings.device.session.isDetached()).toBe(true);
});
it("removes a remote device after process discovery", async () => {
const bindings = new FakeDeviceManager();
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "attach",
remote: { address: "127.0.0.1:27042", token: "secret-token" },
pid: 512,
});
expect(started.ok).toBe(true);
if (!started.ok) return;
const listed = await manager.listProcesses({
remote: { address: "127.0.0.1:27042", token: "secret-token" },
});
expect(listed.ok).toBe(true);
if (listed.ok) {
expect(listed.value.processes[0]?.pid).toBe(512);
expect(listed.value.cleanupError).toBeNull();
}
expect(bindings.removedRemote).toEqual([]);
await manager.closeSession(started.value.sessionId);
expect(bindings.removedRemote).toEqual(["127.0.0.1:27042"]);
await manager.closeAll();
});
it("preserves remote discovery facts when cleanup fails and retries cleanup", async () => {
const bindings = new FakeDeviceManager();
bindings.removeRemoteFailureCount = 1;
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const listed = await manager.listProcesses({
remote: { address: "127.0.0.1:27042" },
});
expect(listed.ok).toBe(true);
if (listed.ok) {
expect(listed.value.processes).toEqual([
{ pid: 512, name: "fixture", identifier: null },
]);
expect(listed.value.cleanupError).toContain(
"remote device removal failed",
);
}
await manager.closeAll();
expect(bindings.removedRemote).toEqual(["127.0.0.1:27042"]);
});
it("does not expose remote credentials in shutdown cleanup failures", async () => {
const token = "synthetic-close-token";
const certificate = "synthetic-close-certificate";
const bindings = new FakeDeviceManager();
bindings.removeRemoteFailureCount = 2;
bindings.removeRemoteFailureMessage = `${token} ${certificate}`;
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "attach",
remote: { address: "127.0.0.1:27042", token, certificate },
pid: 512,
});
expect(started.ok).toBe(true);
if (!started.ok) return;
let failure = "";
try {
await manager.closeAll();
} catch (cause: unknown) {
failure =
cause instanceof AggregateError
? cause.errors.map((entry: unknown) => String(entry)).join("; ")
: String(cause);
}
expect(failure).not.toBe("");
expect(failure).not.toContain(token);
expect(failure).not.toContain(certificate);
});
it("unloads scripts, resumes a paused spawn, and detaches without killing it", async () => {
const bindings = new FakeDeviceManager();
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
});
expect(started.ok).toBe(true);
if (!started.ok) return;
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('ready');",
});
expect(loaded.ok).toBe(true);
const closed = await manager.closeSession(started.value.sessionId);
expect(closed.ok).toBe(true);
expect(bindings.device.session.scripts[0]?.isDestroyed).toBe(true);
expect(bindings.device.resumed).toEqual([512]);
expect(bindings.device.session.isDetached()).toBe(true);
});
it("retains a failed script handle until a later cleanup retry releases it", async () => {
const bindings = new FakeDeviceManager();
bindings.device.session.nextLoadFailure = new Error("script load failed");
bindings.device.session.nextUnloadFailureCount = 1;
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "attach",
deviceId: "local",
pid: 512,
});
expect(started.ok).toBe(true);
if (!started.ok) return;
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('ready');",
});
expect(loaded.ok).toBe(false);
if (!loaded.ok) expect(loaded.error.cleanupIncomplete).toBe(true);
expect(manager.status(started.value.sessionId)?.scripts).toHaveLength(1);
expect(bindings.device.session.scripts[0]?.unloadCalls).toBe(1);
const closed = await manager.closeSession(started.value.sessionId);
expect(closed.ok).toBe(true);
expect(bindings.device.session.scripts[0]?.unloadCalls).toBe(2);
expect(bindings.device.session.scripts[0]?.isDestroyed).toBe(true);
expect(manager.status(started.value.sessionId)).toBeUndefined();
});
it("closes a session when its target already destroyed the script", async () => {
const { bindings, manager, started } = await startLocalAttachedSession();
expect(started.ok).toBe(true);
if (!started.ok) return;
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('ready');",
});
expect(loaded.ok).toBe(true);
const script = bindings.device.session.scripts[0];
expect(script).toBeDefined();
if (script === undefined) return;
script.isDestroyed = true;
bindings.device.session.markDetached();
const closed = await manager.closeSession(started.value.sessionId);
expect(closed.ok).toBe(true);
expect(script.unloadCalls).toBe(0);
expect(manager.status(started.value.sessionId)).toBeUndefined();
});
it("does not resume a paused spawn after its parent session detached", async () => {
const bindings = new FakeDeviceManager();
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
});
expect(started.ok).toBe(true);
if (!started.ok) return;
bindings.device.session.markDetachedWithoutSignal();
const closed = await manager.closeSession(started.value.sessionId);
expect(closed.ok).toBe(true);
expect(bindings.device.resumed).toEqual([]);
expect(manager.status(started.value.sessionId)).toBeUndefined();
});
it("uses successful parent-session detach to release a script after unload fails", async () => {
const { bindings, manager, started } = await startLocalAttachedSession();
expect(started.ok).toBe(true);
if (!started.ok) return;
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('ready');",
});
expect(loaded.ok).toBe(true);
const script = bindings.device.session.scripts[0];
expect(script).toBeDefined();
if (script === undefined) return;
script.unloadFailureCount = 1;
const closed = await manager.closeSession(started.value.sessionId);
expect(closed.ok).toBe(true);
expect(script.isDestroyed).toBe(true);
expect(manager.status(started.value.sessionId)).toBeUndefined();
});
it("keeps a paused spawn paused when script unload and session detach fail", async () => {
const bindings = new FakeDeviceManager();
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
});
expect(started.ok).toBe(true);
if (!started.ok) return;
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('ready');",
});
expect(loaded.ok).toBe(true);
const script = bindings.device.session.scripts[0];
expect(script).toBeDefined();
if (script === undefined) return;
script.unloadFailureCount = 1;
bindings.device.session.detachFailureCount = 1;
const closed = await manager.closeSession(started.value.sessionId);
expect(closed.ok).toBe(false);
expect(bindings.device.resumed).toEqual([]);
expect(manager.status(started.value.sessionId)?.scripts).toHaveLength(1);
});
it("retries resuming a spawn after detach succeeded but resume failed", async () => {
const bindings = new FakeDeviceManager();
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
});
expect(started.ok).toBe(true);
if (!started.ok) return;
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('ready');",
});
expect(loaded.ok).toBe(true);
bindings.device.resumeFailureCount = 1;
const firstClose = await manager.closeSession(started.value.sessionId);
expect(firstClose.ok).toBe(false);
expect(bindings.device.session.isDetached()).toBe(true);
expect(manager.status(started.value.sessionId)).toBeDefined();
const retry = await manager.closeSession(started.value.sessionId);
expect(retry.ok).toBe(true);
expect(bindings.device.resumed).toEqual([512]);
expect(manager.status(started.value.sessionId)).toBeUndefined();
});
it("keeps a remote device while a spawned target resume is pending", async () => {
const bindings = new FakeDeviceManager();
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "spawn",
remote: { address: "127.0.0.1:27042" },
program: "/tmp/fixture",
});
expect(started.ok).toBe(true);
if (!started.ok) return;
bindings.device.resumeFailureCount = 1;
const firstClose = await manager.closeSession(started.value.sessionId);
expect(firstClose.ok).toBe(false);
expect(bindings.removedRemote).toEqual([]);
const retry = await manager.closeSession(started.value.sessionId);
expect(retry.ok).toBe(true);
expect(bindings.device.resumed).toEqual([512]);
expect(bindings.removedRemote).toEqual(["127.0.0.1:27042"]);
});
it("keeps a remote device while script and session cleanup are pending", async () => {
const bindings = new FakeDeviceManager();
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "attach",
remote: { address: "127.0.0.1:27042" },
pid: 512,
});
expect(started.ok).toBe(true);
if (!started.ok) return;
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('ready');",
});
expect(loaded.ok).toBe(true);
const script = bindings.device.session.scripts[0];
expect(script).toBeDefined();
if (script === undefined) return;
script.unloadFailureCount = 1;
bindings.device.session.detachFailureCount = 1;
const firstClose = await manager.closeSession(started.value.sessionId);
expect(firstClose.ok).toBe(false);
expect(bindings.removedRemote).toEqual([]);
const retry = await manager.closeSession(started.value.sessionId);
expect(retry.ok).toBe(true);
expect(script.isDestroyed).toBe(true);
expect(bindings.removedRemote).toEqual(["127.0.0.1:27042"]);
});
});
it("does not release a newer remote lease when retrying an older session close", async () => {
const bindings = new FakeDeviceManager();
bindings.removeRemoteFailureCount = 1;
const manager = new FridaInstrumentationManager({ deviceManager: bindings });
const remote = { address: "127.0.0.1:27042" };
const first = await manager.startSession({
mode: "attach",
remote,
pid: 512,
});
expect(first.ok).toBe(true);
if (!first.ok) return;
const failedClose = await manager.closeSession(first.value.sessionId);
expect(failedClose.ok).toBe(false);
const second = await manager.startSession({
mode: "attach",
remote,
pid: 512,
});
expect(second.ok).toBe(true);
if (!second.ok) return;
const retry = await manager.closeSession(first.value.sessionId);
expect(retry.ok).toBe(true);
expect(bindings.removedRemote).toEqual([]);
expect(manager.status(second.value.sessionId)).toBeDefined();
const secondClose = await manager.closeSession(second.value.sessionId);
expect(secondClose.ok).toBe(true);
expect(bindings.removedRemote).toEqual([remote.address]);
});
it("attributes messages to their producing script and returns each load's messages", async () => {
const { bindings, manager, started } = await startLocalAttachedSession();
expect(started.ok).toBe(true);
if (!started.ok) return;
bindings.device.session.nextPayload = { tag: "A" };
const first = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send({tag: 'A'});",
});
expect(first.ok).toBe(true);
if (!first.ok) return;
bindings.device.session.nextPayload = { tag: "B" };
const second = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send({tag: 'B'});",
});
expect(second.ok).toBe(true);
if (!second.ok) return;
expect(first.value.messages).toEqual([
expect.objectContaining({
script_id: first.value.scriptId,
payload: { tag: "A" },
}),
]);
expect(second.value.messages).toEqual([
expect.objectContaining({
script_id: second.value.scriptId,
payload: { tag: "B" },
}),
]);
expect(manager.status(started.value.sessionId)?.messages).toEqual([
expect.objectContaining({ script_id: first.value.scriptId }),
expect.objectContaining({ script_id: second.value.scriptId }),
]);
await manager.closeAll();
});
it("keeps an active script message bucket after its last message is evicted", async () => {
const { bindings, manager, started } = await startLocalAttachedSession();
const session = bindings.device.session;
expect(started.ok).toBe(true);
if (!started.ok) return;
session.nextPayload = { tag: "A" };
const first = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send({tag: 'A'});",
});
expect(first.ok).toBe(true);
if (!first.ok) return;
let finishSecondLoad: (() => void) | undefined;
let markSecondLoadStarted: (() => void) | undefined;
const secondLoadGate = new Promise<void>((resolve) => {
finishSecondLoad = resolve;
});
const secondLoadStarted = new Promise<void>((resolve) => {
markSecondLoadStarted = resolve;
});
session.nextPayload = { tag: "B2" };
session.scriptLoadGate = secondLoadGate;
session.onScriptLoadStarted = () => markSecondLoadStarted?.();
const secondLoading = manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send({tag: 'B2'});",
});
await secondLoadStarted;
const secondScript = session.scripts[1];
expect(secondScript).toBeDefined();
if (secondScript === undefined) return;
secondScript.message.emit({ type: "send", payload: { tag: "B1" } }, null);
const firstScript = session.scripts[0];
expect(firstScript).toBeDefined();
if (firstScript === undefined) return;
for (let index = 0; index < 800; index += 1)
firstScript.message.emit(
{ type: "send", payload: "x".repeat(32_700) },
null,
);
finishSecondLoad?.();
const second = await secondLoading;
expect(second.ok).toBe(true);
if (second.ok) {
expect(second.value.messages).toContainEqual(
expect.objectContaining({
script_id: second.value.scriptId,
payload: { tag: "B2" },
}),
);
expect(second.value.messagesTruncated).toBe(true);
}
expect(manager.status(started.value.sessionId)?.messages).toContainEqual(
expect.objectContaining({
script_id: second.ok ? second.value.scriptId : "",
payload: { tag: "B2" },
}),
);
await manager.closeAll();
});
it("serializes a final remote release against a new same-endpoint session", async () => {
const bindings = new FakeDeviceManager();
let enteredRemoval: (() => void) | undefined;
const removalEntered = new Promise<void>((resolve) => {
enteredRemoval = resolve;
});
let finishRemoval: (() => void) | undefined;
const removalGate = new Promise<void>((resolve) => {
finishRemoval = resolve;
});
bindings.removeRemoteDevice = async (address) => {
enteredRemoval?.();
await removalGate;
bindings.removedRemote.push(address);
};
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const remote = { address: "127.0.0.1:27042", token: "same-token" };
const first = await manager.startSession({
mode: "attach",
remote,
pid: 512,
});
expect(first.ok).toBe(true);
if (!first.ok) return;
const closing = manager.closeSession(first.value.sessionId);
await removalEntered;
let secondStarted = false;
const secondPromise = manager
.startSession({ mode: "attach", remote, pid: 512 })
.then((result) => {
secondStarted = true;
return result;
});
await Promise.resolve();
expect(secondStarted).toBe(false);
finishRemoval?.();
expect((await closing).ok).toBe(true);
const second = await secondPromise;
expect(second.ok).toBe(true);
if (second.ok) await manager.closeSession(second.value.sessionId);
expect(bindings.removedRemote).toEqual([
"127.0.0.1:27042",
"127.0.0.1:27042",
]);
});
it("redacts remote credentials and maps a missing optional binding", async () => {
const secret = "synthetic-secret-token";
const remoteManager = new FakeDeviceManager();
remoteManager.addRemoteDevice = async () => {
throw new Error(`authorization rejected: ${secret}`);
};
const remote = new FridaInstrumentationManager({
deviceManager: remoteManager,
});
const failed = await remote.listProcesses({
remote: {
address: "wss://host/path?token=synthetic-url-token",
token: secret,
},
});
expect(failed.ok).toBe(false);
if (!failed.ok) {
expect(failed.error.message).not.toContain(secret);
expect(failed.error.message).not.toContain("synthetic-url-token");
}
const missing = new FridaInstrumentationManager({
deviceManagerLoader: async () => {
throw new Error("Cannot find package 'frida'");
},
});
const unavailable = await missing.listDevices();
expect(unavailable.ok).toBe(false);
if (!unavailable.ok)
expect(unavailable.error.message).toContain("Cannot find package 'frida'");
});
it("redacts remote credentials echoed by a target script", async () => {
const token = "synthetic-token-from-auth";
const certificate = "synthetic-certificate-from-auth";
const bindings = new FakeDeviceManager();
bindings.device.session.nextPayload = { token, certificate };
const manager = new FridaInstrumentationManager({ deviceManager: bindings });
const started = await manager.startSession({
mode: "attach",
remote: { address: "127.0.0.1:27042", token, certificate },
pid: 512,
});
expect(started.ok).toBe(true);
if (!started.ok) return;
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('credential payload');",
});
expect(loaded.ok).toBe(true);
if (loaded.ok) {
const serialized = JSON.stringify(loaded.value.messages);
expect(serialized).not.toContain(token);
expect(serialized).not.toContain(certificate);
expect(serialized).toContain("[redacted]");
}
await manager.closeAll();
});
it("bounds oversized Frida message metadata before retaining it", async () => {
const bindings = new FakeDeviceManager();
bindings.device.session.nextDescription = "d".repeat(1_000_000);
bindings.device.session.nextStack = "s".repeat(1_000_000);
const manager = new FridaInstrumentationManager({ deviceManager: bindings });
const started = await manager.startSession({
mode: "attach",
deviceId: "local",
pid: 512,
});
expect(started.ok).toBe(true);
if (!started.ok) return;
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('metadata');",
});
expect(loaded.ok).toBe(true);
if (loaded.ok) {
expect(loaded.value.messagesTruncated).toBe(true);
const message = loaded.value.messages[0];
expect(message).toMatchObject({
description_truncated: true,
stack_truncated: true,
});
expect(JSON.stringify(message).length).toBeLessThan(100_000);
}
await manager.closeAll();
});
it("serializes script loading against concurrent close and unloads the loaded script", async () => {
const bindings = new FakeDeviceManager();
let finishScriptLoad: (() => void) | undefined;
const scriptLoadGate = new Promise<void>((resolve) => {
finishScriptLoad = resolve;
});
let markScriptLoadStarted: (() => void) | undefined;
const scriptLoadStarted = new Promise<void>((resolve) => {
markScriptLoadStarted = resolve;
});
bindings.device.session.scriptLoadGate = scriptLoadGate;
bindings.device.session.onScriptLoadStarted = () => markScriptLoadStarted?.();
const manager = new FridaInstrumentationManager({ deviceManager: bindings });
const started = await manager.startSession({
mode: "attach",
deviceId: "local",
pid: 512,
});
expect(started.ok).toBe(true);
if (!started.ok) return;
const loading = manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('loaded');",
});
await scriptLoadStarted;
let closed = false;
const closing = manager.closeSession(started.value.sessionId);
void closing.then(() => {
closed = true;
});
expect(manager.closeSession(started.value.sessionId)).toBe(closing);
await Promise.resolve();
expect(closed).toBe(false);
finishScriptLoad?.();
expect((await loading).ok).toBe(true);
expect((await closing).ok).toBe(true);
expect(bindings.device.session.scripts[0]?.isDestroyed).toBe(true);
expect(bindings.device.session.isDetached()).toBe(true);
});

View File

@ -0,0 +1,117 @@
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { OptionalProviderLoadFailure } from "../OptionalObservationProviders.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import type { Result } from "../../domain/result.js";
export interface FridaRemoteConnection {
readonly address: string;
readonly token?: string;
readonly certificate?: string;
readonly origin?: string;
readonly keepaliveInterval?: number;
}
export interface FridaDeviceSelector {
readonly deviceId?: string;
readonly remote?: FridaRemoteConnection;
}
export interface FridaDeviceObservation {
readonly deviceId: string;
readonly name: string;
readonly type: string;
}
export interface FridaProcessObservation {
readonly pid: number;
readonly name: string;
readonly identifier: string | null;
}
export type StartFridaSessionInput = FridaDeviceSelector &
(
| { readonly mode: "attach"; readonly pid: number }
| {
readonly mode: "spawn";
readonly program: string;
readonly argv?: readonly string[];
}
);
export type FridaScriptSource =
| { readonly sourceKind: "inline"; readonly source: string }
| { readonly sourceKind: "file"; readonly path: string };
export interface FridaSessionObservation {
readonly sessionId: string;
readonly deviceId: string;
readonly target: string;
readonly pid: number;
readonly mode: "attach" | "spawn";
readonly state: "paused" | "running" | "detached";
}
export interface FridaScriptObservation {
readonly scriptId: string;
readonly sourceKind: "inline" | "file";
readonly sourcePath: string | null;
readonly sourceSha256: string;
readonly messages: readonly JsonValue[];
readonly messagesTruncated: boolean;
}
export interface FridaSessionStatus extends FridaSessionObservation {
/** True only after REA has successfully resumed this spawned target. */
readonly targetResumeObserved: boolean;
readonly scripts: readonly {
readonly scriptId: string;
readonly name: string;
}[];
readonly messages: readonly JsonValue[];
readonly messagesTruncated: boolean;
}
export interface FridaSessionCleanupObservation {
readonly targetResumeObserved: boolean;
}
/** Shared application boundary for Frida runtime instrumentation workflows. */
export interface FridaInstrumentationPort {
availabilityFailure?(): OptionalProviderLoadFailure | undefined;
listDevices(remote?: FridaRemoteConnection): Promise<
Result<
{
readonly devices: readonly FridaDeviceObservation[];
readonly cleanupError: string | null;
},
AnalysisError
>
>;
listProcesses(selector: FridaDeviceSelector): Promise<
Result<
{
readonly deviceId: string;
readonly processes: readonly FridaProcessObservation[];
readonly cleanupError: string | null;
},
AnalysisError
>
>;
startSession(
input: StartFridaSessionInput,
): Promise<Result<FridaSessionObservation, AnalysisError>>;
loadScript(
sessionId: string,
source: FridaScriptSource,
): Promise<Result<FridaScriptObservation, AnalysisError>>;
resumeSession(sessionId: string): Promise<Result<null, AnalysisError>>;
unloadScript(
sessionId: string,
scriptId: string,
): Promise<Result<null, AnalysisError>>;
status(sessionId: string): FridaSessionStatus | undefined;
closeSession(
sessionId: string,
): Promise<Result<FridaSessionCleanupObservation, AnalysisError>>;
closeAll(): Promise<void>;
}

View File

@ -0,0 +1,508 @@
import { expect, it } from "vitest";
import type { FridaInstrumentationPort } from "./FridaInstrumentationPort.js";
import { FridaInstrumentationService } from "./FridaInstrumentationService.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
const createSession = () => ({
sessionId: "00000000-0000-4000-8000-000000000001",
deviceId: "local",
target: "fixture",
pid: 10,
mode: "spawn" as const,
state: "paused" as const,
});
const createScriptObservation = () => ({
scriptId: "00000000-0000-4000-8000-000000000002",
sourceKind: "inline" as const,
sourcePath: null,
sourceSha256: "a".repeat(64),
messages: [],
messagesTruncated: false,
});
const createProvider = (
overrides: Partial<FridaInstrumentationPort> = {},
): FridaInstrumentationPort => ({
async listDevices() {
return { ok: true, value: { devices: [], cleanupError: null } };
},
async listProcesses() {
return {
ok: true,
value: { deviceId: "local", processes: [], cleanupError: null },
};
},
async startSession() {
return { ok: true, value: createSession() };
},
async loadScript() {
return { ok: true, value: createScriptObservation() };
},
async resumeSession() {
return { ok: true, value: null };
},
async unloadScript() {
return { ok: true, value: null };
},
status() {
return undefined;
},
async closeSession() {
return { ok: true, value: { targetResumeObserved: false } };
},
async closeAll() {},
...overrides,
});
it.each(["startSession", "instrument"] as const)(
"does not start Frida when %s receives an already-cancelled signal",
async (method) => {
let starts = 0;
let loads = 0;
const provider = createProvider({
async startSession() {
starts += 1;
return { ok: true, value: createSession() };
},
async loadScript() {
loads += 1;
return { ok: true, value: createScriptObservation() };
},
});
const service = new FridaInstrumentationService(provider);
const controller = new AbortController();
controller.abort(new Error("cancelled before call"));
const result =
method === "startSession"
? await service.startSession(
{ mode: "spawn", deviceId: "local", program: "/tmp/fixture" },
controller.signal,
)
: await service.instrument(
{
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
source: { sourceKind: "inline", source: "send('ready');" },
durationMs: 1,
},
controller.signal,
);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError");
expect(starts).toBe(0);
expect(loads).toBe(0);
},
);
it("does not resume a spawned target when cancellation arrives during script loading", async () => {
const controller = new AbortController();
let resumes = 0;
let closes = 0;
const service = new FridaInstrumentationService(
createProvider({
async loadScript() {
controller.abort(new Error("cancelled while loading"));
return { ok: true, value: createScriptObservation() };
},
async resumeSession() {
resumes += 1;
return { ok: true, value: null };
},
async closeSession() {
closes += 1;
return { ok: true, value: { targetResumeObserved: true } };
},
}),
);
const result = await service.instrument(
{
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
source: { sourceKind: "inline", source: "send('ready');" },
durationMs: 1,
},
controller.signal,
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.partialObservation).toMatchObject({
normalized_result: {
cleanup_error: null,
target_state_before_cleanup: "paused",
target_resume_observed: true,
instrumentation_cleanup: "released",
target_liveness_after_cleanup: "unknown",
},
parameters: {
mode: "spawn",
program: "/tmp/fixture",
},
});
}
expect(resumes).toBe(0);
expect(closes).toBe(1);
});
it("reports the spawned session when cancellation arrives during resume", async () => {
const controller = new AbortController();
let closes = 0;
const service = new FridaInstrumentationService(
createProvider({
async resumeSession() {
controller.abort(new Error("cancelled while resuming"));
return { ok: true, value: null };
},
status() {
return {
...createSession(),
state: "running",
targetResumeObserved: true,
scripts: [],
messages: [],
messagesTruncated: false,
};
},
async closeSession() {
closes += 1;
return { ok: true, value: { targetResumeObserved: false } };
},
}),
);
const result = await service.instrument(
{
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
argv: ["--owned-fixture"],
source: { sourceKind: "inline", source: "send('ready');" },
durationMs: 60_000,
},
controller.signal,
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.partialObservation).toMatchObject({
normalized_result: {
target_state_before_cleanup: "running",
target_resume_observed: true,
instrumentation_cleanup: "released",
target_liveness_after_cleanup: "unknown",
},
parameters: {
argv: ["--owned-fixture"],
},
});
}
expect(closes).toBe(1);
});
it("does not infer a Frida resume for a running attached target on cancellation", async () => {
const controller = new AbortController();
const service = new FridaInstrumentationService(
createProvider({
async loadScript() {
controller.abort(new Error("cancelled while loading"));
return { ok: true, value: createScriptObservation() };
},
status() {
return {
...createSession(),
mode: "attach",
state: "running",
targetResumeObserved: false,
scripts: [],
messages: [],
messagesTruncated: false,
};
},
async closeSession() {
return { ok: true, value: { targetResumeObserved: false } };
},
}),
);
const result = await service.instrument(
{
mode: "attach",
deviceId: "local",
pid: 512,
source: { sourceKind: "inline", source: "send('ready');" },
durationMs: 0,
},
controller.signal,
);
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error.partialObservation).toMatchObject({
normalized_result: {
target_state_before_cleanup: "running",
target_resume_observed: false,
},
});
});
it("retains an observed spawn resume after the target detaches before cancellation", async () => {
const controller = new AbortController();
const service = new FridaInstrumentationService(
createProvider({
async loadScript() {
controller.abort(new Error("cancelled after target exit"));
return { ok: true, value: createScriptObservation() };
},
status() {
return {
...createSession(),
state: "detached",
targetResumeObserved: true,
scripts: [],
messages: [],
messagesTruncated: false,
};
},
async closeSession() {
return { ok: true, value: { targetResumeObserved: false } };
},
}),
);
const result = await service.instrument(
{
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
source: { sourceKind: "inline", source: "send('ready');" },
durationMs: 0,
},
controller.signal,
);
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error.partialObservation).toMatchObject({
normalized_result: {
target_state_before_cleanup: "detached",
target_resume_observed: true,
},
});
});
it.each([
{ cleanupState: "running" as const, resumeObserved: true },
{ cleanupState: "paused" as const, resumeObserved: false },
])(
"uses a spawned target state transition after failed cleanup ($cleanupState)",
async ({ cleanupState, resumeObserved }) => {
const controller = new AbortController();
let statusCalls = 0;
const service = new FridaInstrumentationService(
createProvider({
async loadScript() {
controller.abort(new Error("cancelled while loading"));
return { ok: true, value: createScriptObservation() };
},
status() {
statusCalls += 1;
return {
...createSession(),
state: statusCalls === 1 ? "paused" : cleanupState,
targetResumeObserved: false,
scripts: [],
messages: [],
messagesTruncated: false,
};
},
async closeSession() {
return {
ok: false,
error: new AnalysisCapabilityUnavailableError(
"frida",
"close_frida_session",
"session cleanup failed",
),
};
},
}),
);
const result = await service.instrument(
{
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
source: { sourceKind: "inline", source: "send('ready');" },
durationMs: 0,
},
controller.signal,
);
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error.partialObservation).toMatchObject({
normalized_result: {
instrumentation_cleanup: "incomplete_or_unknown",
target_state_before_cleanup: "paused",
target_resume_observed: resumeObserved,
},
});
},
);
it("records target selection in Evidence without remote authentication values", async () => {
const token = "synthetic-frida-token";
const certificate = "synthetic-frida-certificate";
const service = new FridaInstrumentationService(createProvider());
const result = await service.instrument({
mode: "spawn",
remote: {
address: "127.0.0.1:27042",
token,
certificate,
origin: "rea-test",
keepaliveInterval: 1_000,
},
program: "/tmp/fixture",
argv: ["--marker", "selected-argument"],
source: { sourceKind: "inline", source: "send('ready');" },
durationMs: 1,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.evidence.parameters).toMatchObject({
session_id: createSession().sessionId,
remote: {
address: "127.0.0.1:27042",
origin: "rea-test",
keepalive_interval: 1_000,
},
mode: "spawn",
program: "/tmp/fixture",
argv: ["--marker", "selected-argument"],
});
const parameters = JSON.stringify(result.value.evidence.parameters);
expect(parameters).not.toContain(token);
expect(parameters).not.toContain(certificate);
});
it("reports completed cleanup when closing the parent session releases a failed script", async () => {
const service = new FridaInstrumentationService(
createProvider({
async loadScript() {
return {
ok: false,
error: new AnalysisCapabilityUnavailableError(
"frida",
"load_frida_script",
"script load failed",
{
cleanup: {
reason: "script unload failed",
resources: ["script-1"],
},
},
),
};
},
}),
);
const result = await service.instrument({
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
source: { sourceKind: "inline", source: "send('ready');" },
durationMs: 1,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.cleanupIncomplete).toBe(false);
});
it("cleans up a session and returns typed cancellation after a pending start", async () => {
let finishStart: (() => void) | undefined;
let markStartCalled: (() => void) | undefined;
const startGate = new Promise<void>((resolve) => {
finishStart = resolve;
});
const startCalled = new Promise<void>((resolve) => {
markStartCalled = resolve;
});
const closed: string[] = [];
const provider: FridaInstrumentationPort = {
async listDevices() {
return { ok: true, value: { devices: [], cleanupError: null } };
},
async listProcesses() {
return {
ok: true,
value: { deviceId: "local", processes: [], cleanupError: null },
};
},
async startSession() {
markStartCalled?.();
await startGate;
return {
ok: true,
value: {
sessionId: "00000000-0000-4000-8000-000000000001",
deviceId: "local",
target: "fixture",
pid: 10,
mode: "attach",
state: "running",
},
};
},
async loadScript() {
return {
ok: false,
error: new AnalysisInputError("load_frida_script"),
};
},
async resumeSession() {
return { ok: true, value: null };
},
async unloadScript() {
return { ok: true, value: null };
},
status() {
return undefined;
},
async closeSession(sessionId) {
closed.push(sessionId);
return { ok: true, value: { targetResumeObserved: false } };
},
async closeAll() {},
};
const service = new FridaInstrumentationService(provider);
const controller = new AbortController();
const pending = service.startSession(
{ mode: "attach", deviceId: "local", pid: 10 },
controller.signal,
);
await startCalled;
controller.abort(new Error("caller cancelled"));
finishStart?.();
const result = await pending;
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError");
expect(closed).toEqual(["00000000-0000-4000-8000-000000000001"]);
});

View File

@ -0,0 +1,400 @@
import { setTimeout as delay } from "node:timers/promises";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import { jsonObjectSchema, jsonValueSchema } from "../../domain/jsonValue.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
} from "../../domain/analysisErrorCore.js";
import { err, ok, type Result } from "../../domain/result.js";
import type {
FridaDeviceSelector,
FridaInstrumentationPort,
FridaRemoteConnection,
FridaScriptObservation,
FridaScriptSource,
FridaSessionObservation,
FridaSessionStatus,
StartFridaSessionInput,
} from "./FridaInstrumentationPort.js";
/** Shared CLI/MCP workflows for Frida's device and instrumentation operations. */
export class FridaInstrumentationService {
readonly #provider: FridaInstrumentationPort;
constructor(provider: FridaInstrumentationPort) {
this.#provider = provider;
}
listDevices(remote?: FridaRemoteConnection) {
return this.#provider.listDevices(remote);
}
listProcesses(selector: FridaDeviceSelector) {
return this.#provider.listProcesses(selector);
}
async startSession(input: StartFridaSessionInput, signal?: AbortSignal) {
return this.#startSession(input, signal, "start_frida_session");
}
async #startSession(
input: StartFridaSessionInput,
signal: AbortSignal | undefined,
operation: string,
) {
if (signal?.aborted)
return err(
new AnalysisCancelledError(
operation,
signal.reason === undefined ? {} : { cause: signal.reason },
),
);
const started = await this.#provider.startSession(input);
if (!started.ok || !signal?.aborted) return started;
return this.#cancelSession(operation, started.value, input, signal.reason);
}
async #cancelSession(
operation: string,
session: FridaSessionObservation,
input: StartFridaSessionInput,
cause: unknown,
loaded?: FridaScriptObservation,
): Promise<Result<never, AnalysisError>> {
const status = this.#provider.status(session.sessionId);
const observation = status ?? {
...session,
targetResumeObserved: false,
scripts: [],
messages: loaded?.messages ?? [],
messagesTruncated: loaded?.messagesTruncated ?? false,
};
const closed = await this.#provider.closeSession(session.sessionId);
const statusAfterCleanup = this.#provider.status(session.sessionId);
const partialObservation = fridaEvidence(
operation,
{
...sessionStatusOutput(observation),
...(loaded === undefined
? {}
: {
source_kind: loaded.sourceKind,
source_path: loaded.sourcePath,
source_sha256: loaded.sourceSha256,
}),
cleanup_error: closed.ok ? null : closed.error.message,
target_state_before_cleanup: observation.state,
target_resume_observed:
observation.targetResumeObserved ||
(observation.mode === "spawn" && observation.state === "running") ||
(closed.ok && closed.value.targetResumeObserved) ||
(observation.mode === "spawn" &&
observation.state !== "running" &&
statusAfterCleanup?.state === "running"),
instrumentation_cleanup: closed.ok
? "released"
: "incomplete_or_unknown",
target_liveness_after_cleanup: "unknown",
},
fridaParameters(session.sessionId, input, session.deviceId),
);
return err(
new AnalysisCancelledError(operation, {
...(cause === undefined ? {} : { cause }),
partialObservation,
...(closed.ok
? {}
: {
cleanup: {
reason: closed.error.message,
resources: [session.sessionId],
},
}),
}),
);
}
async loadScript(
sessionId: string,
source: FridaScriptSource,
): Promise<Result<Evidence, AnalysisError>> {
const loaded = await this.#provider.loadScript(sessionId, source);
return loaded.ok
? ok(
fridaEvidence("load_frida_script", scriptOutput(loaded.value), {
session_id: sessionId,
}),
)
: loaded;
}
resumeSession(sessionId: string) {
return this.#provider.resumeSession(sessionId);
}
unloadScript(sessionId: string, scriptId: string) {
return this.#provider.unloadScript(sessionId, scriptId);
}
status(sessionId: string): FridaSessionStatus | undefined {
return this.#provider.status(sessionId);
}
statusEvidence(sessionId: string): Evidence | undefined {
const status = this.#provider.status(sessionId);
return status === undefined
? undefined
: fridaEvidence("frida_session_status", sessionStatusOutput(status), {
session_id: sessionId,
});
}
async instrument(
input: StartFridaSessionInput & {
readonly source: FridaScriptSource;
readonly durationMs: number;
},
signal?: AbortSignal,
): Promise<
Result<
{ readonly evidence: Evidence; readonly cleanupError: string | null },
AnalysisError
>
> {
const started = await this.#startSession(
input,
signal,
"instrument_with_frida",
);
if (!started.ok) return started;
if (signal?.aborted)
return this.#cancelSession(
"instrument_with_frida",
started.value,
input,
signal.reason,
);
const loaded = await this.#provider.loadScript(
started.value.sessionId,
input.source,
);
if (signal?.aborted)
return this.#cancelSession(
"instrument_with_frida",
started.value,
input,
signal.reason,
loaded.ok ? loaded.value : undefined,
);
if (!loaded.ok) {
const closed = await this.#provider.closeSession(started.value.sessionId);
return closed.ok
? err(withRecoveredFridaCleanup(loaded.error))
: err(
withCleanupFailure(
loaded.error,
closed.error.message,
started.value.sessionId,
),
);
}
if (started.value.mode === "spawn") {
const resumed = await this.#provider.resumeSession(
started.value.sessionId,
);
if (signal?.aborted)
return this.#cancelSession(
"instrument_with_frida",
started.value,
input,
signal.reason,
loaded.value,
);
if (!resumed.ok) {
const closed = await this.#provider.closeSession(
started.value.sessionId,
);
return closed.ok
? resumed
: err(
withCleanupFailure(
resumed.error,
closed.error.message,
started.value.sessionId,
),
);
}
}
try {
await delay(input.durationMs, undefined, { signal });
} catch (cause: unknown) {
return this.#cancelSession(
"instrument_with_frida",
started.value,
input,
cause,
loaded.value,
);
}
if (signal?.aborted)
return this.#cancelSession(
"instrument_with_frida",
started.value,
input,
signal.reason,
loaded.value,
);
const status = this.#provider.status(started.value.sessionId);
const observation = status ?? {
...started.value,
targetResumeObserved: false,
scripts: [],
messages: loaded.value.messages,
messagesTruncated: loaded.value.messagesTruncated,
};
const closed = await this.#provider.closeSession(started.value.sessionId);
const cleanupError = closed.ok ? null : closed.error.message;
const evidence = fridaEvidence(
"instrument_with_frida",
{
...sessionStatusOutput(observation),
source_kind: loaded.value.sourceKind,
source_path: loaded.value.sourcePath,
source_sha256: loaded.value.sourceSha256,
cleanup_error: cleanupError,
},
fridaParameters(started.value.sessionId, input, started.value.deviceId),
);
return ok({
evidence,
cleanupError,
});
}
closeSession(sessionId: string) {
return this.#provider.closeSession(sessionId);
}
closeAll(): Promise<void> {
return this.#provider.closeAll();
}
}
const fridaEvidence = (
operation: string,
result: unknown,
parameters: unknown,
): Evidence =>
createEvidence(
undefined,
{ id: "frida", name: "Frida", version: null },
{
predicateType: "rea.frida.instrumentation-observation",
operation,
parameters: jsonObjectSchema.parse(parameters),
result: jsonValueSchema.parse(result),
confidence: "observed",
authority: "external-service",
environment: null,
limitations: [
"The target operating system and architecture were not reported by this observation.",
],
},
);
const fridaParameters = (
sessionId: string,
input: StartFridaSessionInput,
deviceId: string,
) => ({
session_id: sessionId,
device_id: deviceId,
...(input.remote === undefined
? {}
: {
remote: {
address: input.remote.address,
...(input.remote.origin === undefined
? {}
: { origin: input.remote.origin }),
...(input.remote.keepaliveInterval === undefined
? {}
: { keepalive_interval: input.remote.keepaliveInterval }),
},
}),
mode: input.mode,
...(input.mode === "attach"
? { pid: input.pid }
: {
program: input.program,
...(input.argv === undefined ? {} : { argv: input.argv }),
}),
});
const withCleanupFailure = (
original: AnalysisError,
cleanupReason: string,
sessionId: string,
): AnalysisError =>
new AnalysisCapabilityUnavailableError(
"frida",
"instrument_with_frida",
`${original.message}; cleanup failed: ${cleanupReason}`,
{
cause: original,
cleanup: {
reason: cleanupReason,
resources: [sessionId],
},
},
);
/** Preserve the original Frida failure after its owner confirms cleanup. */
export const withRecoveredFridaCleanup = (
error: AnalysisError,
): AnalysisError =>
error instanceof AnalysisCapabilityUnavailableError && error.cleanupIncomplete
? new AnalysisCapabilityUnavailableError(
error.providerId,
error.operation,
error.reason,
{
cause: error.cause,
...(error.capturedOutput === undefined
? {}
: { capturedOutput: error.capturedOutput }),
...(error.partialObservation === undefined
? {}
: { partialObservation: error.partialObservation }),
...(error.userMessage === undefined
? {}
: { userMessage: error.userMessage }),
},
)
: error;
const scriptOutput = (script: FridaScriptObservation) => ({
script_id: script.scriptId,
source_kind: script.sourceKind,
source_path: script.sourcePath,
source_sha256: script.sourceSha256,
messages: script.messages,
messages_truncated: script.messagesTruncated,
});
const sessionStatusOutput = (status: FridaSessionStatus) => ({
session_id: status.sessionId,
device_id: status.deviceId,
state: status.state,
target: status.target,
pid: status.pid,
mode: status.mode,
scripts: status.scripts.map((script) => ({
script_id: script.scriptId,
name: script.name,
})),
messages: status.messages,
messages_truncated: status.messagesTruncated,
});

View File

@ -5,6 +5,8 @@ import { registerEvmCommands } from "./cli/evmCommands.js";
import { registerGoCommands } from "./cli/goCommands.js";
import { registerAnalysisViewCommands } from "./cli/analysisViewCommands.js";
import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsCommands.js";
import { registerFridaCommands } from "./cli/fridaCommands.js";
import type { FridaInstrumentationPort } from "./application/frida/FridaInstrumentationPort.js";
import { Cli } from "incur";
import { createLogger, parseLogLevel } from "./logger.js";
@ -62,6 +64,7 @@ export const createCli = (
Record<string, string | undefined>
> = process.env,
resultOutput?: CliResultOutput,
fridaInstrumentation?: FridaInstrumentationPort,
): CliInstance => {
const environment = snapshotEnvironment(selectedEnvironment);
const analysis = createDirectAnalysis(environment);
@ -100,6 +103,7 @@ export const createCli = (
registerFirmwareCommands(cli, logger, environment);
registerBinaryDiagnosticsCommands(cli, logger, environment);
registerAnalysisViewCommands(cli, logger);
registerFridaCommands(cli, logger, fridaInstrumentation);
registerEvidenceCommands(cli, logger);
registerProcessCommands(cli, logger, environment);
registerBrowserCommands(cli, logger);

275
src/cli/fridaCommands.ts Normal file
View File

@ -0,0 +1,275 @@
import { z } from "incur";
import {
FridaInstrumentationService,
withRecoveredFridaCleanup,
} from "../application/frida/FridaInstrumentationService.js";
import type {
FridaInstrumentationPort,
FridaRemoteConnection,
} from "../application/frida/FridaInstrumentationPort.js";
import { FridaInstrumentationManager } from "../frida/FridaInstrumentationManager.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { err, type Result } from "../domain/result.js";
import { FRIDA_KEEPALIVE_INTERVAL_DESCRIPTION } from "../contracts/fridaToolContracts.js";
import { isValidFridaRemoteAddress } from "../contracts/fridaRemoteAddress.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
/** Register Frida discovery and one-shot instrumentation CLI operations. */
export const registerFridaCommands = (
cli: CliInstance,
logger: Logger,
provider: FridaInstrumentationPort = new FridaInstrumentationManager(),
): void => {
const service = new FridaInstrumentationService(provider);
// CLI calls own a disposable provider; MCP retains its shared provider until
// server shutdown. Await cleanup here, including failed startup resources.
const withProviderCleanup = async <Value>(
operation: () => Promise<Result<Value, AnalysisError>>,
): Promise<Result<Value, AnalysisError>> => {
const result = await operation();
try {
await provider.closeAll();
} catch (cause: unknown) {
const cleanup = new ProviderCleanupError(
"frida",
["Frida sessions or connections"],
result.ok
? { partial_observation: jsonValueSchema.parse(result.value) }
: {},
{ cause, operation: "frida_cleanup" },
);
return err(
result.ok
? cleanup
: analysisErrorWithCleanupFailure(
result.error,
cleanup,
"frida_cleanup",
),
);
}
return result.ok ? result : err(withRecoveredFridaCleanup(result.error));
};
const remoteOptions = z.object({
remoteAddress: z
.string()
.min(1)
.max(2048)
.refine(isValidFridaRemoteAddress)
.optional()
.describe("Remote Frida host address, optionally with a port"),
token: z.string().min(1).optional().describe("Remote authentication token"),
certificate: z
.string()
.min(1)
.optional()
.describe("Remote TLS certificate"),
origin: z.string().min(1).optional().describe("Remote connection origin"),
keepaliveInterval: z
.number()
.int()
.positive()
.optional()
.describe(FRIDA_KEEPALIVE_INTERVAL_DESCRIPTION),
});
const remoteFrom = (
options: z.infer<typeof remoteOptions>,
): FridaRemoteConnection | undefined =>
options.remoteAddress === undefined
? undefined
: {
address: options.remoteAddress,
...(options.token === undefined ? {} : { token: options.token }),
...(options.certificate === undefined
? {}
: { certificate: options.certificate }),
...(options.origin === undefined ? {} : { origin: options.origin }),
...(options.keepaliveInterval === undefined
? {}
: { keepaliveInterval: options.keepaliveInterval }),
};
const remoteAddressRequired = (
options: z.infer<typeof remoteOptions>,
): boolean =>
options.remoteAddress !== undefined ||
(options.token === undefined &&
options.certificate === undefined &&
options.origin === undefined &&
options.keepaliveInterval === undefined);
cli.command(CLI_COMMANDS.listFridaDevices, {
description: "List local Frida devices and an optional remote endpoint",
args: z.object({}),
options: remoteOptions.refine(
remoteAddressRequired,
"Remote credentials and connection options require remoteAddress",
),
run: ({ options }) =>
logCliCommand(logger, CLI_COMMANDS.listFridaDevices, async () => {
const result = await withProviderCleanup(() =>
service.listDevices(remoteFrom(options)),
);
return result.ok
? {
devices: result.value.devices,
cleanup_error: result.value.cleanupError,
}
: projectAnalysisError(result.error);
}),
});
cli.command(CLI_COMMANDS.listFridaProcesses, {
description: "List processes visible through a Frida device",
args: z.object({}),
options: remoteOptions
.extend({
deviceId: z
.string()
.min(1)
.optional()
.describe("Frida device identifier"),
})
.refine(
(options) =>
options.remoteAddress === undefined || options.deviceId === undefined,
"Choose either deviceId or remoteAddress, not both",
)
.refine(
remoteAddressRequired,
"Remote credentials and connection options require remoteAddress",
),
run: ({ options }) =>
logCliCommand(logger, CLI_COMMANDS.listFridaProcesses, async () => {
const remote = remoteFrom(options);
const result = await withProviderCleanup(() =>
service.listProcesses(
remote === undefined
? { deviceId: options.deviceId ?? "local" }
: { remote },
),
);
return result.ok
? {
device_id: result.value.deviceId,
processes: result.value.processes,
cleanup_error: result.value.cleanupError,
}
: projectAnalysisError(result.error);
}),
});
cli.command(CLI_COMMANDS.instrumentWithFrida, {
description: "Run one Frida script against a selected process and detach",
args: z.object({
mode: z
.enum(["attach", "spawn"])
.describe("Attach to a PID or spawn a program"),
target: z
.string()
.min(1)
.describe("Process ID for attach or program path for spawn"),
}),
options: remoteOptions
.extend({
deviceId: z
.string()
.min(1)
.optional()
.describe("Frida device identifier"),
script: z.string().optional().describe("Inline JavaScript to load"),
scriptFile: z
.string()
.min(1)
.optional()
.describe("Path to JavaScript source file"),
argument: z
.array(z.string())
.optional()
.describe("Arguments passed to a spawned target"),
durationMs: z
.number()
.int()
.min(0)
.max(60_000)
.default(1_000)
.describe("Instrumentation duration in milliseconds"),
})
.refine(
(options) =>
options.remoteAddress === undefined || options.deviceId === undefined,
"Choose either deviceId or remoteAddress, not both",
)
.refine(
remoteAddressRequired,
"Remote credentials and connection options require remoteAddress",
),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.instrumentWithFrida, async () => {
if (
(options.script === undefined) ===
(options.scriptFile === undefined)
)
return projectAnalysisError(
new AnalysisInputError(CLI_COMMANDS.instrumentWithFrida),
);
const remote = remoteFrom(options);
const selected =
remote === undefined
? { deviceId: options.deviceId ?? "local" }
: { remote };
const targetInput =
args.mode === "attach"
? /^[1-9][0-9]*$/u.test(args.target) &&
Number.isSafeInteger(Number(args.target)) &&
Number(args.target) > 0
? {
...selected,
mode: "attach" as const,
pid: Number(args.target),
}
: undefined
: {
...selected,
mode: "spawn" as const,
program: args.target,
...(options.argument === undefined
? {}
: { argv: options.argument }),
};
if (targetInput === undefined)
return projectAnalysisError(
new AnalysisInputError(CLI_COMMANDS.instrumentWithFrida),
);
const result = await withProviderCleanup(() =>
service.instrument(
{
...targetInput,
source:
options.scriptFile === undefined
? { sourceKind: "inline", source: options.script ?? "" }
: { sourceKind: "file", path: options.scriptFile },
durationMs: options.durationMs,
},
signal,
),
);
if (!result.ok) return projectAnalysisError(result.error);
if (result.value.cleanupError !== null) process.exitCode = 1;
return {
evidence: result.value.evidence,
cleanup_error: result.value.cleanupError,
};
}),
),
});
};

View File

@ -45,6 +45,9 @@ export const CLI_COMMANDS = Object.freeze({
inspectGoBinary: "inspect-go-binary",
inspectPeResources: "inspect-pe-resources",
inspectAnalysisView: "inspect-analysis-view",
listFridaDevices: "list-frida-devices",
listFridaProcesses: "list-frida-processes",
instrumentWithFrida: "instrument-with-frida",
inspectEvidenceBundle: "inspect-evidence-bundle",
inspectFirmwareRegions: "inspect-firmware-regions",
extractFirmware: "extract-firmware",
@ -149,6 +152,9 @@ export const CLI_COMMAND_TOOL_ALIASES = Object.freeze({
search: ["search_strings", "search_procedures"],
"capture-process": ["capture_process_scenario"],
"compare-bundles": ["compare_bundles"],
"list-frida-devices": ["list_frida_devices"],
"list-frida-processes": ["list_frida_processes"],
"instrument-with-frida": ["instrument_with_frida"],
});
/** MCP operations without a dedicated CLI command or direct command alias. */
@ -206,4 +212,10 @@ export const MCP_TOOLS_WITHOUT_DEDICATED_CLI = Object.freeze([
"get_evidence_bundle",
"get_navigation_context",
"inspect_address_context",
"start_frida_session",
"load_frida_script",
"resume_frida_session",
"unload_frida_script",
"frida_session_status",
"close_frida_session",
]);

View File

@ -0,0 +1,21 @@
/** Accept a Frida host address, optionally with a TCP port, without URL credentials or paths. */
export const isValidFridaRemoteAddress = (address: string): boolean => {
if (address.length === 0 || address.length > 2048) return false;
if (/[?@#\s\u0000-\u001f\u007f/]/u.test(address)) return false;
if (address.endsWith(":")) return false;
try {
const parsed = new URL(`tcp://${address}`);
const port = parsed.port === "" ? undefined : Number(parsed.port);
return (
parsed.hostname.length > 0 &&
(port === undefined || (port > 0 && port <= 65_535)) &&
parsed.username.length === 0 &&
parsed.password.length === 0 &&
(parsed.pathname === "" || parsed.pathname === "/") &&
parsed.search.length === 0 &&
parsed.hash.length === 0
);
} catch {
return false;
}
};

View File

@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";
import { TOOL_CONTRACTS } from "./toolContracts.js";
import { FRIDA_KEEPALIVE_INTERVAL_DESCRIPTION } from "./fridaToolContracts.js";
import { isValidFridaRemoteAddress } from "./fridaRemoteAddress.js";
const contract = (name: string) => {
const found = TOOL_CONTRACTS.find((candidate) => candidate.name === name);
if (found === undefined) throw new Error(`Missing contract ${name}`);
return found.inputSchema;
};
describe("Frida tool contracts", () => {
it("documents Frida Core keepalive intervals in seconds", () => {
expect(FRIDA_KEEPALIVE_INTERVAL_DESCRIPTION).toBe(
"Remote keepalive interval in seconds",
);
});
it("rejects incomplete or conflicting mode and source selectors", () => {
const start = contract("start_frida_session");
const load = contract("load_frida_script");
const instrument = contract("instrument_with_frida");
const processList = contract("list_frida_processes");
expect(start.safeParse({ mode: "attach", pid: 7 }).success).toBe(true);
expect(start.safeParse({ mode: "spawn", program: "app" }).success).toBe(
true,
);
for (const input of [
{ mode: "attach" },
{ mode: "attach", pid: 7, program: "app" },
{ mode: "spawn" },
{ mode: "spawn", program: "app", pid: 7 },
{
mode: "attach",
pid: 7,
device_id: "local",
remote: { address: "host" },
},
])
expect(start.safeParse(input).success).toBe(false);
expect(
load.safeParse({
session_id: "00000000-0000-4000-8000-000000000001",
source_kind: "inline",
source: "",
}).success,
).toBe(true);
expect(
load.safeParse({
session_id: "00000000-0000-4000-8000-000000000001",
source_kind: "file",
path: "script.js",
}).success,
).toBe(true);
for (const input of [
{
session_id: "00000000-0000-4000-8000-000000000001",
source_kind: "inline",
},
{
session_id: "00000000-0000-4000-8000-000000000001",
source_kind: "inline",
source: "",
path: "script.js",
},
{
session_id: "00000000-0000-4000-8000-000000000001",
source_kind: "file",
},
])
expect(load.safeParse(input).success).toBe(false);
expect(
instrument.safeParse({
mode: "attach",
pid: 7,
source_kind: "inline",
source: "",
}).success,
).toBe(true);
expect(
instrument.safeParse({
mode: "spawn",
program: "app",
source_kind: "file",
path: "script.js",
}).success,
).toBe(true);
for (const input of [
{
mode: "attach",
pid: 7,
source_kind: "file",
path: "script.js",
source: "",
},
{ mode: "spawn", program: "app", source_kind: "inline" },
{
mode: "spawn",
program: "app",
pid: 7,
source_kind: "inline",
source: "",
},
{
mode: "attach",
source_kind: "inline",
source: "",
device_id: "local",
remote: { address: "host" },
},
])
expect(instrument.safeParse(input).success).toBe(false);
expect(processList.safeParse({ device_id: "local" }).success).toBe(true);
expect(processList.safeParse({}).success).toBe(false);
expect(
processList.safeParse({ device_id: "local", remote: { address: "host" } })
.success,
).toBe(false);
});
it("publishes the Frida device, process, and session operations", () => {
expect(TOOL_CONTRACTS.map(({ name }) => name)).toEqual(
expect.arrayContaining([
"list_frida_devices",
"list_frida_processes",
"start_frida_session",
"load_frida_script",
"resume_frida_session",
"unload_frida_script",
"frida_session_status",
"close_frida_session",
"instrument_with_frida",
]),
);
});
it("declares target authority for every operation that changes instrumentation", () => {
for (const name of [
"start_frida_session",
"load_frida_script",
"resume_frida_session",
"unload_frida_script",
"close_frida_session",
"instrument_with_frida",
] as const) {
const contract = TOOL_CONTRACTS.find(
(candidate) => candidate.name === name,
);
expect(contract?.effects.mutatesTarget).toBe(true);
expect(contract?.effects.idempotent).toBe(false);
}
});
it("accepts Frida host addresses and rejects URL-like or malformed endpoints", () => {
expect(isValidFridaRemoteAddress("127.0.0.1:27042")).toBe(true);
expect(isValidFridaRemoteAddress("frida-target.example")).toBe(true);
expect(isValidFridaRemoteAddress("[::1]:27042")).toBe(true);
expect(isValidFridaRemoteAddress("tcp://127.0.0.1:27042")).toBe(false);
expect(isValidFridaRemoteAddress("127.0.0.1:70000")).toBe(false);
expect(isValidFridaRemoteAddress("127.0.0.1:0")).toBe(false);
expect(isValidFridaRemoteAddress("127.0.0.1:")).toBe(false);
expect(isValidFridaRemoteAddress("frida-target/path")).toBe(false);
expect(isValidFridaRemoteAddress("user@frida-target")).toBe(false);
});
});

View File

@ -0,0 +1,327 @@
import { z } from "zod";
import type { ToolContract } from "./toolContractTypes.js";
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
import { toolContractMetadata } from "./toolEffects.js";
import { isValidFridaRemoteAddress } from "./fridaRemoteAddress.js";
export const FRIDA_KEEPALIVE_INTERVAL_DESCRIPTION =
"Remote keepalive interval in seconds";
const remoteConnection = z.strictObject({
address: z.string().min(1).max(2048).refine(isValidFridaRemoteAddress, {
message: "Expected a Frida host address, optionally followed by a port",
}),
token: z.string().min(1).optional(),
certificate: z.string().min(1).optional(),
origin: z.string().min(1).optional(),
keepalive_interval: z
.number()
.int()
.positive()
.describe(FRIDA_KEEPALIVE_INTERVAL_DESCRIPTION)
.optional(),
});
const deviceSelector = z.strictObject({
device_id: z.string().min(1).optional(),
remote: remoteConnection.optional(),
});
const exclusiveDeviceSelector = <Schema extends z.ZodTypeAny>(schema: Schema) =>
schema.refine(
(value) =>
!(
typeof value === "object" &&
value !== null &&
"device_id" in value &&
"remote" in value &&
value.device_id !== undefined &&
value.remote !== undefined
),
"Choose either device_id or remote, not both",
);
const sessionId = z.string().uuid();
const sessionOutput = z.strictObject({
session_id: sessionId,
device_id: z.string(),
target: z.string(),
pid: z.number().int().nonnegative(),
mode: z.enum(["attach", "spawn"]),
state: z.enum(["paused", "running", "detached"]),
});
const scriptOutput = z.strictObject({
script_id: z.string().uuid(),
source_kind: z.enum(["inline", "file"]),
source_path: z.string().nullable(),
source_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
messages: z.array(z.unknown()),
messages_truncated: z.boolean(),
});
/** Caller-facing Frida device, process, and instrumentation-session contracts. */
export const FRIDA_TOOL_CONTRACTS = [
{
name: "list_frida_devices",
...toolContractMetadata("list_frida_devices"),
kind: "native-provider",
description:
"List Frida devices currently available to this host, optionally connecting to the caller-selected remote Frida endpoint. Remote authentication options are used for this call only and are never returned or recorded.",
inputSchema: z.strictObject({ remote: remoteConnection.optional() }),
outputSchema: z.strictObject({
devices: z.array(
z.strictObject({
device_id: z.string(),
name: z.string(),
type: z.string(),
}),
),
cleanup_error: z.string().nullable(),
}),
examples: [{ title: "List Frida devices", input: {} }],
},
{
name: "list_frida_processes",
...toolContractMetadata("list_frida_processes"),
kind: "native-provider",
description:
"List processes observed on a selected Frida device or caller-supplied remote endpoint. A remote connection is closed after discovery; authentication values are never returned or recorded.",
inputSchema: exclusiveDeviceSelector(deviceSelector).refine(
(value) => value.device_id !== undefined || value.remote !== undefined,
"Select a device_id or a remote endpoint",
),
outputSchema: z.strictObject({
device_id: z.string(),
processes: z.array(
z.strictObject({
pid: z.number().int().nonnegative(),
name: z.string(),
identifier: z.string().nullable(),
}),
),
cleanup_error: z.string().nullable(),
}),
examples: [
{
title: "List local Frida processes",
input: { device_id: "local" },
},
],
},
{
name: "start_frida_session",
...toolContractMetadata("start_frida_session"),
kind: "native-provider",
description:
"Attach Frida to one selected process or spawn one selected target. Spawned targets remain paused until resume_frida_session, allowing scripts to load before execution. Closing a session detaches instrumentation; it does not kill the target.",
inputSchema: z
.strictObject({
mode: z.enum(["attach", "spawn"]),
...deviceSelector.shape,
pid: z.number().int().positive().optional(),
program: z.string().min(1).optional(),
argv: z.array(z.string()).optional(),
})
.refine(
(value) =>
((value.mode === "attach" &&
value.pid !== undefined &&
value.program === undefined &&
value.argv === undefined) ||
(value.mode === "spawn" &&
value.program !== undefined &&
value.pid === undefined)) &&
!(value.device_id !== undefined && value.remote !== undefined),
"Select one device; attach requires pid; spawn requires program and rejects pid",
),
outputSchema: sessionOutput,
examples: [
{
title: "Attach to a process",
input: { mode: "attach", device_id: "local", pid: 1234 },
},
],
},
{
name: "load_frida_script",
...toolContractMetadata("load_frida_script"),
kind: "native-provider",
description:
"Load caller-supplied JavaScript into a Frida session. Scripts can read or change target memory and behavior and can access the target process's capabilities. Messages and script errors are returned inline as Evidence; source content is not persisted.",
inputSchema: z
.strictObject({
session_id: sessionId,
source_kind: z.enum(["inline", "file"]),
source: z.string().optional(),
path: z.string().min(1).optional(),
})
.refine(
(value) =>
(value.source_kind === "inline" &&
value.source !== undefined &&
value.path === undefined) ||
(value.source_kind === "file" &&
value.path !== undefined &&
value.source === undefined),
"Inline source requires source; file source requires path",
),
outputSchema: evidenceResultOf(scriptOutput),
examples: [
{
title: "Load an inline Frida script",
input: {
session_id: "00000000-0000-4000-8000-000000000001",
source_kind: "inline",
source: "send(Process.id);",
},
},
],
},
{
name: "resume_frida_session",
...toolContractMetadata("resume_frida_session"),
kind: "native-provider",
description:
"Resume the process created by a paused Frida spawn session. Attached processes are already running and cannot be resumed through this operation.",
inputSchema: z.strictObject({ session_id: sessionId }),
outputSchema: z.strictObject({ state: z.literal("running") }),
examples: [
{
title: "Resume an instrumented process",
input: { session_id: "00000000-0000-4000-8000-000000000001" },
},
],
},
{
name: "unload_frida_script",
...toolContractMetadata("unload_frida_script"),
kind: "native-provider",
description:
"Unload one caller-selected Frida script from a live session. This removes that script's instrumentation from the target.",
inputSchema: z.strictObject({
session_id: sessionId,
script_id: z.string().uuid(),
}),
outputSchema: z.strictObject({ unloaded: z.literal(true) }),
examples: [
{
title: "Unload a script",
input: {
session_id: "00000000-0000-4000-8000-000000000001",
script_id: "00000000-0000-4000-8000-000000000002",
},
},
],
},
{
name: "frida_session_status",
...toolContractMetadata("frida_session_status"),
kind: "native-provider",
description:
"Return observed Frida session state and queued script messages. Message history is bounded by the provider capture budget and reports truncation.",
inputSchema: z.strictObject({ session_id: sessionId }),
outputSchema: evidenceResultOf(
z.strictObject({
session_id: sessionId,
device_id: z.string(),
state: z.enum(["paused", "running", "detached"]),
target: z.string(),
pid: z.number().int().nonnegative(),
mode: z.enum(["attach", "spawn"]),
scripts: z.array(
z.strictObject({ script_id: z.string().uuid(), name: z.string() }),
),
messages: z.array(z.unknown()),
messages_truncated: z.boolean(),
}),
),
examples: [
{
title: "Check Frida session",
input: { session_id: "00000000-0000-4000-8000-000000000001" },
},
],
},
{
name: "close_frida_session",
...toolContractMetadata("close_frida_session"),
kind: "native-provider",
description:
"Unload session scripts and detach REA's Frida session. If a spawned process is still paused, resume it before detaching so it is not left suspended. REA never kills the target; its final state is reported as unknown.",
inputSchema: z.strictObject({ session_id: sessionId }),
outputSchema: z.strictObject({ target_state: z.literal("unknown") }),
examples: [
{
title: "Close Frida session",
input: { session_id: "00000000-0000-4000-8000-000000000001" },
},
],
},
{
name: "instrument_with_frida",
...toolContractMetadata("instrument_with_frida"),
kind: "native-provider",
description:
"Run one caller-authored Frida script against an explicitly selected process and detach when the observation window ends. The CLI and MCP share this ephemeral workflow; use MCP session operations for persistent sessions. Remote authentication is used only for this call and excluded from Evidence.",
inputSchema: z
.strictObject({
mode: z.enum(["attach", "spawn"]),
...deviceSelector.shape,
pid: z.number().int().positive().optional(),
program: z.string().min(1).optional(),
argv: z.array(z.string()).optional(),
source_kind: z.enum(["inline", "file"]),
source: z.string().optional(),
path: z.string().min(1).optional(),
duration_ms: z.number().int().min(0).max(60_000).default(1_000),
})
.refine(
(value) =>
!(value.device_id !== undefined && value.remote !== undefined) &&
((value.mode === "attach" &&
value.pid !== undefined &&
value.program === undefined &&
value.argv === undefined) ||
(value.mode === "spawn" &&
value.program !== undefined &&
value.pid === undefined)) &&
((value.source_kind === "inline" &&
value.source !== undefined &&
value.path === undefined) ||
(value.source_kind === "file" &&
value.path !== undefined &&
value.source === undefined)),
"Select one device and provide fields matching the mode and source_kind",
),
outputSchema: evidenceResultOf(
z.strictObject({
...sessionOutput.shape,
scripts: z.array(
z.strictObject({ script_id: sessionId, name: z.string() }),
),
messages: z.array(z.unknown()),
messages_truncated: z.boolean(),
source_kind: z.enum(["inline", "file"]),
source_path: z.string().nullable(),
source_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
cleanup_error: z.string().nullable(),
}),
),
examples: [
{
title: "Instrument a selected process briefly",
input: {
mode: "attach",
device_id: "local",
pid: 1234,
source_kind: "inline",
source: "send(Process.id);",
duration_ms: 1_000,
},
},
],
},
] as const satisfies readonly ToolContract[];

View File

@ -81,4 +81,27 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly<
left_bundle_path: "/tmp/left-evidence.json",
right_bundle_path: "/tmp/right-evidence.json",
},
list_frida_devices: {},
list_frida_processes: { device_id: "local" },
start_frida_session: { mode: "attach", device_id: "local", pid: 1234 },
load_frida_script: {
session_id: "00000000-0000-4000-8000-000000000001",
source_kind: "inline",
source: "send(Process.id);",
},
resume_frida_session: { session_id: "00000000-0000-4000-8000-000000000001" },
unload_frida_script: {
session_id: "00000000-0000-4000-8000-000000000001",
script_id: "00000000-0000-4000-8000-000000000002",
},
frida_session_status: { session_id: "00000000-0000-4000-8000-000000000001" },
close_frida_session: { session_id: "00000000-0000-4000-8000-000000000001" },
instrument_with_frida: {
mode: "attach",
device_id: "local",
pid: 1234,
source_kind: "inline",
source: "send(Process.id);",
duration_ms: 1_000,
},
};

View File

@ -23,6 +23,7 @@ import { JAVASCRIPT_RECOVERY_TOOL_CONTRACTS } from "./javascript/javascriptRecov
import { OFFICIAL_TOOL_CONTRACTS } from "./officialToolContracts.js";
import { ENHANCED_TOOL_CONTRACTS } from "./enhancedToolContracts.js";
import { SESSION_TOOL_CONTRACTS } from "./sessionToolContracts.js";
import { FRIDA_TOOL_CONTRACTS } from "./fridaToolContracts.js";
/** Complete ordered public inventory used by registration and verification. */
export const TOOL_CONTRACTS = [
@ -51,6 +52,7 @@ export const TOOL_CONTRACTS = [
...WEB_NETWORK_CAPTURE_TOOL_CONTRACTS,
...JAVASCRIPT_RECOVERY_TOOL_CONTRACTS,
...SESSION_TOOL_CONTRACTS,
...FRIDA_TOOL_CONTRACTS,
] as const;
/** Resolve a public contract by name while retaining its exact schema types. */

View File

@ -13,6 +13,8 @@ const EXECUTION_WORDING =
/\b(?:run|runs|running|launch|launches|launching|execute|executes|executing|execution|start|starts|install|installs|uninstall|uninstalls)\b/iu;
const EXECUTION_WORDING_EXCEPTIONS: Readonly<Record<string, string>> = {
start_frida_session:
"spawned targets remain suspended until resume_frida_session",
inspect_native_load_image: "states that it does not execute the target",
resolve_native_call_targets: "states that it does not execute code",
inspect_pe_resources: "states that it does not execute the image",
@ -117,8 +119,11 @@ describe("tool effects", () => {
"capture_native_ui_scenario",
"capture_process_scenario",
"install_adb_package",
"instrument_with_frida",
"load_frida_script",
"observe_native_calls",
"push_adb_file",
"resume_frida_session",
"start_adb_activity",
"start_adb_app",
"stop_adb_app",

View File

@ -98,6 +98,57 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
writesFilesystem: true,
launchesProcess: true,
}),
list_frida_devices: effects({ launchesProcess: true, accessesNetwork: true }),
list_frida_processes: effects({
launchesProcess: true,
accessesNetwork: true,
}),
start_frida_session: effects({
mutatesTarget: true,
mutatesSession: true,
launchesProcess: true,
accessesNetwork: true,
idempotent: false,
}),
load_frida_script: effects({
mutatesTarget: true,
mutatesSession: true,
launchesProcess: true,
accessesNetwork: true,
executesForeignCode: true,
idempotent: false,
}),
resume_frida_session: effects({
mutatesTarget: true,
mutatesSession: true,
launchesProcess: true,
accessesNetwork: true,
executesForeignCode: true,
idempotent: false,
}),
unload_frida_script: effects({
mutatesTarget: true,
mutatesSession: true,
launchesProcess: true,
accessesNetwork: true,
idempotent: false,
}),
frida_session_status: effects(),
close_frida_session: effects({
mutatesTarget: true,
mutatesSession: true,
launchesProcess: true,
accessesNetwork: true,
idempotent: false,
}),
instrument_with_frida: effects({
mutatesTarget: true,
mutatesSession: true,
launchesProcess: true,
accessesNetwork: true,
executesForeignCode: true,
idempotent: false,
}),
inspect_pe_resources: evidence,
inspect_analysis_view: evidence,
inspect_firmware_regions: effects({

File diff suppressed because it is too large Load Diff

78
src/frida/frida-bindings.d.ts vendored Normal file
View File

@ -0,0 +1,78 @@
declare module "frida" {
export interface FridaMessage {
readonly type: string;
readonly payload?: unknown;
readonly description?: string;
readonly stack?: string;
}
export type SignalHandler = (...args: never[]) => void;
export interface Signal<Handler extends SignalHandler> {
connect(handler: Handler): void;
disconnect(handler: Handler): void;
}
export interface RemoteDeviceOptions {
certificate?: string;
origin?: string;
token?: string;
keepaliveInterval?: number;
}
export interface SpawnOptions {
argv?: string[];
env?: Record<string, string>;
envp?: Record<string, string>;
cwd?: string;
stdio?: "inherit" | "pipe";
}
export interface Process {
readonly pid: number;
readonly name: string;
}
export interface Script {
readonly isDestroyed: boolean;
readonly message: Signal<
(message: FridaMessage, data: Buffer | null) => void
>;
load(): Promise<void>;
unload(): Promise<void>;
}
export interface Session {
readonly pid: number;
readonly detached: Signal<() => void>;
isDetached(): boolean;
createScript(
source: string,
options?: { readonly name?: string },
): Promise<Script>;
detach(): Promise<void>;
}
export interface Device {
readonly id: string;
readonly name: string;
readonly type: string;
enumerateProcesses(): Promise<Process[]>;
getProcessByPid(pid: number): Promise<Process>;
spawn(program: string, options?: SpawnOptions): Promise<number>;
resume(pid: number): Promise<void>;
attach(pid: number): Promise<Session>;
}
export interface DeviceManager {
enumerateDevices(): Promise<Device[]>;
getDeviceById(id: string, timeout: number): Promise<Device>;
addRemoteDevice(
address: string,
options?: RemoteDeviceOptions,
): Promise<Device>;
removeRemoteDevice(address: string): Promise<void>;
}
export function getDeviceManager(): DeviceManager;
}

View File

@ -15,6 +15,8 @@ import { registerGoTools } from "./registerGoTools.js";
import type { RecordedCrashService } from "../application/binaryDiagnostics/RecordedCrashService.js";
import { registerAnalysisViewTool } from "./registerAnalysisViewTool.js";
import { registerBinaryDiagnosticsTools } from "./registerBinaryDiagnosticsTools.js";
import { FridaInstrumentationManager } from "../frida/FridaInstrumentationManager.js";
import { registerFridaTools } from "./registerFridaTools.js";
import { createBinaryLayoutService } from "../composition/binaryDiagnostics.js";
import type { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayoutService.js";
import { EvidenceMcpServer } from "./EvidenceMcpServer.js";
@ -36,6 +38,7 @@ import type { BrowserScenarioCapturePort } from "../application/BrowserScenarioC
import type { ElectronActiveObservationPort } from "../application/javascript/ElectronActiveObservationPort.js";
import type { ElectronObservationPort } from "../application/javascript/ElectronObservationPort.js";
import type { JavaScriptRuntimeObservationPort } from "../application/javascript/JavaScriptRuntimeObservationPort.js";
import type { FridaInstrumentationPort } from "../application/frida/FridaInstrumentationPort.js";
import type { OptionalProviderLoadFailures } from "../application/OptionalObservationProviders.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { silentLogger } from "../logger.js";
@ -130,6 +133,7 @@ export interface CreateServerOptions {
readonly electronObservation?: ElectronObservationPort;
readonly electronActiveObservation?: ElectronActiveObservationPort;
readonly javascriptRuntimeObservation?: JavaScriptRuntimeObservationPort;
readonly fridaInstrumentation?: FridaInstrumentationPort;
readonly availabilityPolicy?: () => AvailabilityPolicy;
readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures;
}
@ -138,13 +142,14 @@ const installSessionToolAvailability = (
session: BinarySessionPort | undefined,
options: CreateServerOptions,
environment: Readonly<NodeJS.ProcessEnv>,
fridaInstrumentation: FridaInstrumentationPort,
) => {
if (session === undefined) return undefined;
const linux = process.platform === "linux";
const linuxX64 = linux && process.arch === "x64";
const pwntools =
linuxX64 && isAbsolute(environment.REA_PWNTOOLS_PYTHON ?? "");
const policy = sessionAvailabilityPolicy(options.availabilityPolicy, {
const basePolicy = sessionAvailabilityPolicy(options.availabilityPolicy, {
optionalProviderLoadFailures: options.optionalProviderLoadFailures,
optionalFeatures: {
wasmArtifactEnabled:
@ -174,6 +179,21 @@ const installSessionToolAvailability = (
options.javascriptRuntimeObservation !== undefined,
},
});
const policy = (): AvailabilityPolicy => {
const value = basePolicy();
const fridaFailure =
options.optionalProviderLoadFailures?.fridaInstrumentation ??
fridaInstrumentation.availabilityFailure?.();
return fridaFailure === undefined
? value
: {
...value,
optionalProviderLoadFailures: {
...value.optionalProviderLoadFailures,
fridaInstrumentation: fridaFailure,
},
};
};
return {
policy,
};
@ -246,6 +266,8 @@ export const createServer = (
);
const android =
options.androidAnalysis ?? createAndroidAnalysisProvider(environment);
const fridaInstrumentation =
options.fridaInstrumentation ?? new FridaInstrumentationManager();
const wasmArtifact = wasmArtifactModule.create(
environment,
options.wasmArtifact,
@ -260,6 +282,7 @@ export const createServer = (
const closeProviders = (): Promise<void> => {
providerClose ??= Promise.allSettled([
android.close(),
fridaInstrumentation.closeAll(),
wasmArtifact.close(),
apktool.close(),
jeb.close(),
@ -288,6 +311,7 @@ export const createServer = (
session,
options,
environment,
fridaInstrumentation,
);
const toolLogger = logger.child({ layer: "server" });
const {
@ -311,6 +335,15 @@ export const createServer = (
artifactResources,
};
registerBinaryAnalysisTools(toolContext);
registerFridaTools(
server,
fridaInstrumentation,
toolLogger,
delivery,
session === undefined
? undefined
: (input) => session.recordEvidence(input),
);
const previousOnclose = server.server.onclose;
server.server.onclose = () => {
previousOnclose?.();

View File

@ -0,0 +1,342 @@
import type { McpServer, ServerContext } from "@modelcontextprotocol/server";
import type { Logger } from "pino";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import { FridaInstrumentationService } from "../application/frida/FridaInstrumentationService.js";
import type { FridaInstrumentationPort } from "../application/frida/FridaInstrumentationPort.js";
import type { FridaRemoteConnection } from "../application/frida/FridaInstrumentationPort.js";
import { toolContract } from "../contracts/toolContracts.js";
import { evidenceSchema } from "../domain/evidence.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { logToolExecution } from "./toolLogging.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import type { ToolResultDelivery } from "./toolResult.js";
/** Register Frida device discovery and persistent instrumentation operations. */
export const registerFridaTools = (
server: McpServer,
provider: FridaInstrumentationPort,
logger: Logger,
delivery: ToolResultDelivery,
recordEvidence?: EvidenceWriter["recordEvidence"],
): void => {
const service = new FridaInstrumentationService(provider);
const listDevices = toolContract("list_frida_devices");
const listProcesses = toolContract("list_frida_processes");
const start = toolContract("start_frida_session");
const load = toolContract("load_frida_script");
const resume = toolContract("resume_frida_session");
const unload = toolContract("unload_frida_script");
const status = toolContract("frida_session_status");
const close = toolContract("close_frida_session");
const instrument = toolContract("instrument_with_frida");
server.registerTool(
listDevices.name,
toolRegistrationOptions(listDevices),
async (input) => {
const result = await logToolExecution(logger, listDevices.name, () =>
service.listDevices(remoteConnection(input.remote)),
);
return result.ok
? delivery.toCallToolResult(
{
ok: true,
value: jsonValueSchema.parse({
devices: result.value.devices.map((device) => ({
device_id: device.deviceId,
name: device.name,
type: device.type,
})),
cleanup_error: result.value.cleanupError,
}),
},
listDevices,
)
: delivery.toCallToolResult(result, listDevices);
},
);
server.registerTool(
listProcesses.name,
toolRegistrationOptions(listProcesses),
async (input) => {
const remote = remoteConnection(input.remote);
const result = await logToolExecution(logger, listProcesses.name, () =>
service.listProcesses({
...(input.device_id === undefined
? {}
: { deviceId: input.device_id }),
...(remote === undefined ? {} : { remote }),
}),
);
return result.ok
? delivery.toCallToolResult(
{
ok: true,
value: jsonValueSchema.parse({
device_id: result.value.deviceId,
processes: result.value.processes,
cleanup_error: result.value.cleanupError,
}),
},
listProcesses,
)
: delivery.toCallToolResult(result, listProcesses);
},
);
server.registerTool(
start.name,
toolRegistrationOptions(start),
async (input, context: ServerContext) => {
const remote = remoteConnection(input.remote);
const selected = {
...(input.device_id === undefined ? {} : { deviceId: input.device_id }),
...(remote === undefined ? {} : { remote }),
};
const result = await logToolExecution(logger, start.name, () =>
input.mode === "attach" && input.pid !== undefined
? service.startSession(
{
...selected,
mode: input.mode,
pid: input.pid,
},
context.mcpReq.signal,
)
: input.mode === "spawn" && input.program !== undefined
? service.startSession(
{
...selected,
mode: input.mode,
program: input.program,
...(input.argv === undefined ? {} : { argv: input.argv }),
},
context.mcpReq.signal,
)
: Promise.resolve({
ok: false as const,
error: new AnalysisInputError(start.name),
}),
);
if (!result.ok) {
recordPartialEvidence(result.error, recordEvidence);
return delivery.toCallToolResult(result, start);
}
return delivery.toCallToolResult(
{
ok: true,
value: jsonValueSchema.parse(sessionOutput(result.value)),
},
start,
);
},
);
server.registerTool(
load.name,
toolRegistrationOptions(load),
async (input) => {
const result = await logToolExecution(logger, load.name, () =>
input.source_kind === "inline" && input.source !== undefined
? service.loadScript(input.session_id, {
sourceKind: "inline",
source: input.source,
})
: input.source_kind === "file" && input.path !== undefined
? service.loadScript(input.session_id, {
sourceKind: "file",
path: input.path,
})
: Promise.resolve({
ok: false as const,
error: new AnalysisInputError(load.name),
}),
);
if (!result.ok) return delivery.toCallToolResult(result, load);
return delivery.toEvidenceToolResult(
result.value,
load,
recordEvidence?.(result.value),
);
},
);
server.registerTool(
resume.name,
toolRegistrationOptions(resume),
async (input) => {
const result = await logToolExecution(logger, resume.name, () =>
service.resumeSession(input.session_id),
);
return result.ok
? delivery.toCallToolResult(
{ ok: true, value: { state: "running" } },
resume,
)
: delivery.toCallToolResult(result, resume);
},
);
server.registerTool(
unload.name,
toolRegistrationOptions(unload),
async (input) => {
const result = await logToolExecution(logger, unload.name, () =>
service.unloadScript(input.session_id, input.script_id),
);
return result.ok
? delivery.toCallToolResult(
{ ok: true, value: { unloaded: true } },
unload,
)
: delivery.toCallToolResult(result, unload);
},
);
server.registerTool(
status.name,
toolRegistrationOptions(status),
async (input) => {
const evidence = service.statusEvidence(input.session_id);
if (evidence === undefined)
return delivery.toCallToolResult(
{
ok: false as const,
error: new AnalysisInputError(status.name),
},
status,
);
return delivery.toEvidenceToolResult(
evidence,
status,
recordEvidence?.(evidence),
);
},
);
server.registerTool(
close.name,
toolRegistrationOptions(close),
async (input) => {
const result = await logToolExecution(logger, close.name, () =>
service.closeSession(input.session_id),
);
return result.ok
? delivery.toCallToolResult(
{ ok: true, value: { target_state: "unknown" } },
close,
)
: delivery.toCallToolResult(result, close);
},
);
server.registerTool(
instrument.name,
toolRegistrationOptions(instrument),
async (input, context: ServerContext) => {
const remote = remoteConnection(input.remote);
const selected = {
...(input.device_id === undefined ? {} : { deviceId: input.device_id }),
...(remote === undefined ? {} : { remote }),
};
const source =
input.source_kind === "inline" && input.source !== undefined
? { sourceKind: "inline" as const, source: input.source }
: input.source_kind === "file" && input.path !== undefined
? { sourceKind: "file" as const, path: input.path }
: undefined;
if (source === undefined)
return delivery.toCallToolResult(
{ ok: false, error: new AnalysisInputError(instrument.name) },
instrument,
);
const operation =
input.mode === "attach" && input.pid !== undefined
? service.instrument(
{
...selected,
mode: "attach",
pid: input.pid,
source,
durationMs: input.duration_ms,
},
context.mcpReq.signal,
)
: input.mode === "spawn" && input.program !== undefined
? service.instrument(
{
...selected,
mode: "spawn",
program: input.program,
...(input.argv === undefined ? {} : { argv: input.argv }),
source,
durationMs: input.duration_ms,
},
context.mcpReq.signal,
)
: Promise.resolve({
ok: false as const,
error: new AnalysisInputError(instrument.name),
});
const result = await logToolExecution(
logger,
instrument.name,
() => operation,
);
if (!result.ok) {
recordPartialEvidence(result.error, recordEvidence);
return delivery.toCallToolResult(result, instrument);
}
return delivery.toEvidenceToolResult(
result.value.evidence,
instrument,
recordEvidence?.(result.value.evidence),
);
},
);
};
const recordPartialEvidence = (
error: { readonly partialObservation?: unknown },
recordEvidence: EvidenceWriter["recordEvidence"] | undefined,
): void => {
if (error.partialObservation === undefined) return;
const parsed = evidenceSchema.safeParse(error.partialObservation);
if (parsed.success) recordEvidence?.(parsed.data);
};
const remoteConnection = (
input:
| {
readonly address: string;
readonly token?: string | undefined;
readonly certificate?: string | undefined;
readonly origin?: string | undefined;
readonly keepalive_interval?: number | undefined;
}
| undefined,
): FridaRemoteConnection | undefined =>
input === undefined
? undefined
: {
address: input.address,
...(typeof input.token === "string" ? { token: input.token } : {}),
...(typeof input.certificate === "string"
? { certificate: input.certificate }
: {}),
...(typeof input.origin === "string" ? { origin: input.origin } : {}),
...(typeof input.keepalive_interval === "number"
? { keepaliveInterval: input.keepalive_interval }
: {}),
};
const sessionOutput = (session: {
readonly sessionId: string;
readonly deviceId: string;
readonly target: string;
readonly pid: number;
readonly mode: "attach" | "spawn";
readonly state: "paused" | "running" | "detached";
}) => ({
session_id: session.sessionId,
device_id: session.deviceId,
target: session.target,
pid: session.pid,
mode: session.mode,
state: session.state,
});

View File

@ -0,0 +1,236 @@
import { expect, it } from "vitest";
import { createCli } from "../../../src/cli.js";
import type { FridaInstrumentationPort } from "../../../src/application/frida/FridaInstrumentationPort.js";
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
import { FridaInstrumentationManager } from "../../../src/frida/FridaInstrumentationManager.js";
it("maps one-shot Frida CLI target, script, duration, and remote options", async () => {
const calls: unknown[] = [];
const provider: FridaInstrumentationPort = {
async listDevices() {
calls.push({ operation: "devices" });
return { ok: true, value: { devices: [], cleanupError: null } };
},
async listProcesses() {
return {
ok: true,
value: { deviceId: "local", processes: [], cleanupError: null },
};
},
async startSession(input) {
calls.push({ operation: "start", input });
return {
ok: true,
value: {
sessionId: "00000000-0000-4000-8000-000000000001",
deviceId: "remote",
target: "fixture",
pid: 71,
mode: input.mode,
state: "running",
},
};
},
async loadScript(sessionId, source) {
calls.push({ operation: "load", sessionId, source });
return {
ok: true,
value: {
scriptId: "00000000-0000-4000-8000-000000000002",
sourceKind: source.sourceKind,
sourcePath: source.sourceKind === "file" ? source.path : null,
sourceSha256: "a".repeat(64),
messages: [{ type: "send", payload: "observed" }],
messagesTruncated: false,
},
};
},
async resumeSession(sessionId) {
calls.push({ operation: "resume", sessionId });
return { ok: true, value: null };
},
async unloadScript() {
return { ok: true, value: null };
},
status(sessionId) {
return {
sessionId,
deviceId: "remote",
target: "fixture",
pid: 71,
mode: "attach",
state: "running",
targetResumeObserved: false,
scripts: [],
messages: [],
messagesTruncated: false,
};
},
async closeSession(sessionId) {
calls.push({ operation: "close", sessionId });
return { ok: true, value: { targetResumeObserved: false } };
},
async closeAll() {},
};
const cli = createCli({}, undefined, provider);
let stdout = "";
let exitCode = 0;
await cli.serve(
[
"instrument-with-frida",
"attach",
"71",
"--script",
"send('hello')",
"--remote-address",
"127.0.0.1:27042",
"--token",
"synthetic-secret-token",
"--keepalive-interval",
"10",
"--duration-ms",
"0",
"--json",
],
{
env: {},
exit: (code) => {
exitCode = code;
},
stdout: (text) => {
stdout += text;
},
},
);
expect(exitCode).toBe(0);
const result: unknown = JSON.parse(stdout);
expect(JSON.stringify(result)).not.toContain("synthetic-secret-token");
expect(calls).toEqual([
{
operation: "start",
input: {
mode: "attach",
pid: 71,
remote: {
address: "127.0.0.1:27042",
token: "synthetic-secret-token",
keepaliveInterval: 10,
},
source: { sourceKind: "inline", source: "send('hello')" },
durationMs: 0,
},
},
{
operation: "load",
sessionId: "00000000-0000-4000-8000-000000000001",
source: { sourceKind: "inline", source: "send('hello')" },
},
{
operation: "close",
sessionId: "00000000-0000-4000-8000-000000000001",
},
]);
stdout = "";
exitCode = 0;
await cli.serve(
["list-frida-devices", "--token", "synthetic-secret-token", "--json"],
{
env: {},
exit: (code) => {
exitCode = code;
},
stdout: (text) => {
stdout += text;
},
},
);
expect(exitCode).not.toBe(0);
expect(calls).toHaveLength(3);
expect(stdout).not.toContain("synthetic-secret-token");
});
it("awaits failed startup cleanup and retains the primary error if cleanup still fails", async () => {
for (const cleanupFails of [false, true]) {
const calls: string[] = [];
const provider: FridaInstrumentationPort =
new FridaInstrumentationManager();
provider.startSession = async () => {
calls.push("start");
return {
ok: false,
error: new AnalysisCapabilityUnavailableError(
"frida",
"start_frida_session",
"attach failed after spawn",
{
cleanup: {
reason: "first resume failed",
resources: ["paused spawn"],
},
},
),
};
};
provider.closeAll = async () => {
calls.push("cleanup");
if (cleanupFails) throw new Error("resume still failed");
};
let stdout = "";
await createCli({}, undefined, provider).serve(
[
"instrument-with-frida",
"spawn",
"/tmp/fixture",
"--script",
"send('ready')",
"--json",
],
{
env: {},
exit: () => {},
stdout: (text) => {
stdout += text;
},
},
);
expect(calls).toEqual(["start", "cleanup"]);
const output: unknown = JSON.parse(stdout);
expect(output).toMatchObject({
code: cleanupFails ? "cleanup_incomplete" : "capability_unavailable",
});
expect(stdout).toContain("attach failed after spawn");
if (cleanupFails) expect(stdout).toContain("primary_error");
}
});
it("preserves observed devices when CLI provider cleanup fails", async () => {
const provider: FridaInstrumentationPort = new FridaInstrumentationManager();
const devices = [
{ deviceId: "remote-device", name: "selected endpoint", type: "remote" },
];
provider.listDevices = async () => ({
ok: true,
value: { devices, cleanupError: null },
});
provider.closeAll = async () => {
throw new Error("remote removal failed");
};
let stdout = "";
await createCli({}, undefined, provider).serve(
["list-frida-devices", "--json"],
{
env: {},
exit: () => {},
stdout: (text) => {
stdout += text;
},
},
);
const output: unknown = JSON.parse(stdout);
expect(output).toMatchObject({
code: "cleanup_incomplete",
details: { diagnostics: { partial_observation: { devices } } },
});
});

View File

@ -9,6 +9,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { SUPPORTED_CLIENT_DEFINITIONS } from "../../../src/application/SupportedClients.js";
import { CLI_COMMAND_NAMES } from "../../../src/cliCommandNames.js";
import { createCli } from "../../../src/cli.js";
import { FRIDA_PROVIDER_IDENTITY } from "../../../src/frida/FridaInstrumentationManager.js";
import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
import {
HOPPER_OPERATIONS,
@ -78,6 +79,14 @@ describe("canonical product catalog", () => {
catalog.providers.find(({ id }) => id === GHIDRA_PROVIDER_IDENTITY.id)
?.capabilities,
).toEqual([...GHIDRA_OPERATIONS].sort());
expect(
catalog.providers.find(({ id }) => id === FRIDA_PROVIDER_IDENTITY.id)
?.capabilities,
).toEqual(
TOOL_CONTRACTS.filter(({ name }) => name.includes("frida"))
.map(({ name }) => name)
.sort(),
);
expect(
JSON.parse(await readFile("docs/public/product-catalog.json", "utf8")),
).toEqual(catalog);

View File

@ -0,0 +1,415 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import type { AnySchema } from "ajv";
import { expect, it, onTestFinished } from "vitest";
import type { FridaInstrumentationPort } from "../../../src/application/frida/FridaInstrumentationPort.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { expandToolAvailability } from "../../../src/contracts/toolOutputSchemaPrimitives.js";
import { FridaInstrumentationManager } from "../../../src/frida/FridaInstrumentationManager.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
const createFridaTestProvider = (
overrides: Partial<FridaInstrumentationPort> = {},
): FridaInstrumentationPort => ({
async listDevices() {
return {
ok: true,
value: {
devices: [
{ deviceId: "remote-device", name: "target", type: "remote" },
],
cleanupError: null,
},
};
},
async listProcesses() {
return {
ok: true,
value: { deviceId: "remote-device", processes: [], cleanupError: null },
};
},
async startSession(input) {
return {
ok: true,
value: {
sessionId: "00000000-0000-4000-8000-000000000001",
deviceId: "remote-device",
target: String(input.mode),
pid: 10,
mode: input.mode,
state: input.mode === "spawn" ? "paused" : "running",
},
};
},
async loadScript() {
return {
ok: true,
value: {
scriptId: "00000000-0000-4000-8000-000000000002",
sourceKind: "inline",
sourcePath: null,
sourceSha256: "a".repeat(64),
messages: [],
messagesTruncated: false,
},
};
},
async resumeSession() {
return { ok: true, value: null };
},
async unloadScript() {
return { ok: true, value: null };
},
status(sessionId) {
return {
sessionId,
deviceId: "remote-device",
target: "fixture",
pid: 10,
mode: "attach",
state: "running",
targetResumeObserved: false,
scripts: [],
messages: [],
messagesTruncated: false,
};
},
async closeSession() {
return { ok: true, value: { targetResumeObserved: false } };
},
async closeAll() {},
...overrides,
});
const expectFridaToolsAvailable = async (client: Client) => {
const inventory = await client.callTool({
name: "binary_session",
arguments: {},
});
const parsedInventory = toolContract("binary_session").outputSchema.safeParse(
inventory.structuredContent,
);
if (!parsedInventory.success)
throw new Error("Invalid binary session output");
const toolAvailability = expandToolAvailability(
parsedInventory.data.result.tool_availability,
);
expect(toolAvailability).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "list_frida_devices",
available: true,
reason: "available",
}),
expect.objectContaining({
name: "instrument_with_frida",
available: true,
reason: "available",
}),
]),
);
};
it("advertises Frida MCP schemas and keeps remote credentials out of results", async () => {
const session = createTestBinarySession(() => {
throw new Error("Binary provider must not start");
});
const server = createServer(
{ kind: "session", session },
{
fridaInstrumentation: createFridaTestProvider(),
},
);
const client = new Client({ name: "frida-contract", version: "1" });
onTestFinished(async () => {
await client.close();
await server.close();
await session.close();
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const tools = (await client.listTools()).tools;
const advertised = tools.find((tool) => tool.name === "list_frida_devices");
if (
advertised?.inputSchema === undefined ||
advertised.outputSchema === undefined
)
throw new Error("Missing advertised Frida schemas");
const ajv = new Ajv2020({ strict: false, validateFormats: false });
const inputSchema: Record<string, unknown> = advertised.inputSchema;
const outputSchema: Record<string, unknown> = advertised.outputSchema;
expect(ajv.validateSchema(inputSchema)).toBe(true);
expect(ajv.validateSchema(outputSchema)).toBe(true);
const response = await client.callTool({
name: "list_frida_devices",
arguments: {
remote: { address: "127.0.0.1:27042", token: "synthetic-secret-token" },
},
});
expect(response.isError).not.toBe(true);
expect(JSON.stringify(response.structuredContent)).not.toContain(
"synthetic-secret-token",
);
expect(ajv.validate(outputSchema, response.structuredContent)).toBe(true);
expect(toolContract("start_frida_session").effects.mutatesTarget).toBe(true);
const processes = await client.callTool({
name: "list_frida_processes",
arguments: { device_id: "remote-device" },
});
expect(processes.isError).not.toBe(true);
const started = await client.callTool({
name: "start_frida_session",
arguments: {
mode: "attach",
pid: 10,
remote: { address: "127.0.0.1:27042", token: "synthetic-secret-token" },
},
});
expect(started.isError).not.toBe(true);
expect(JSON.stringify(started.structuredContent)).not.toContain(
"synthetic-secret-token",
);
const loaded = await client.callTool({
name: "load_frida_script",
arguments: {
session_id: "00000000-0000-4000-8000-000000000001",
source_kind: "inline",
source: "send('loaded');",
},
});
expect(loaded.isError).not.toBe(true);
expect(JSON.stringify(loaded.structuredContent)).not.toContain(
"synthetic-secret-token",
);
const loadedValue = loaded.structuredContent;
if (
loadedValue === undefined ||
typeof loadedValue !== "object" ||
loadedValue === null ||
!("evidence_id" in loadedValue) ||
typeof loadedValue.evidence_id !== "string"
)
throw new Error("Expected inline Frida Evidence");
const retainedEvidence = session.evidenceById(loadedValue.evidence_id);
expect(JSON.stringify(retainedEvidence)).not.toContain(
"synthetic-secret-token",
);
for (const [name, args] of [
[
"resume_frida_session",
{ session_id: "00000000-0000-4000-8000-000000000001" },
],
[
"unload_frida_script",
{
session_id: "00000000-0000-4000-8000-000000000001",
script_id: "00000000-0000-4000-8000-000000000002",
},
],
[
"frida_session_status",
{ session_id: "00000000-0000-4000-8000-000000000001" },
],
[
"close_frida_session",
{ session_id: "00000000-0000-4000-8000-000000000001" },
],
] as const) {
const result = await client.callTool({ name, arguments: args });
expect(
result.isError,
`${name}: ${JSON.stringify(result.structuredContent)}`,
).not.toBe(true);
}
const invalidAddress = await client.callTool({
name: "list_frida_devices",
arguments: {
remote: { address: "tcp://127.0.0.1:27042?token=synthetic-secret-token" },
},
});
expect(invalidAddress.isError).toBe(true);
const oneShot = await client.callTool({
name: "instrument_with_frida",
arguments: {
mode: "attach",
pid: 10,
source_kind: "inline",
source: "send('one-shot');",
duration_ms: 0,
},
});
expect(oneShot.isError).not.toBe(true);
const oneShotContract = tools.find(
(tool) => tool.name === "instrument_with_frida",
);
if (oneShotContract?.outputSchema === undefined)
throw new Error("Missing one-shot Frida output schema");
const validateOneShot = ajv.compile(
oneShotContract.outputSchema as AnySchema,
);
expect(validateOneShot(oneShot.structuredContent)).toBe(true);
await expectFridaToolsAvailable(client);
});
it("reports Frida tools as unavailable after the optional binding fails to load", async () => {
const session = createTestBinarySession(() => {
throw new Error("Binary provider must not start");
});
const provider = new FridaInstrumentationManager({
deviceManagerLoader: async () => {
throw new Error("Cannot find package 'frida'");
},
});
const server = createServer(
{ kind: "session", session },
{ fridaInstrumentation: provider },
);
const client = new Client({ name: "frida-optional-binding", version: "1" });
onTestFinished(async () => {
await client.close();
await server.close();
await session.close();
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const missing = await client.callTool({
name: "list_frida_devices",
arguments: {},
});
expect(missing.isError).toBe(true);
const inventory = await client.callTool({
name: "binary_session",
arguments: {},
});
const parsedInventory = toolContract("binary_session").outputSchema.safeParse(
inventory.structuredContent,
);
if (!parsedInventory.success)
throw new Error("Invalid binary session output");
expect(
expandToolAvailability(parsedInventory.data.result.tool_availability),
).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "list_frida_devices",
available: false,
reason: "provider_unavailable",
remediation: expect.stringContaining("Cannot find package 'frida'"),
}),
]),
);
});
it("retains cancellation Evidence after the MCP client stops waiting", async () => {
const session = createTestBinarySession(() => {
throw new Error("Binary provider must not start");
});
let finishLoad: (() => void) | undefined;
const loadGate = new Promise<void>((resolve) => {
finishLoad = resolve;
});
let markLoadStarted: (() => void) | undefined;
const loadStarted = new Promise<void>((resolve) => {
markLoadStarted = resolve;
});
let markSessionClosed: (() => void) | undefined;
const sessionClosed = new Promise<void>((resolve) => {
markSessionClosed = resolve;
});
const provider = createFridaTestProvider({
async loadScript() {
markLoadStarted?.();
await loadGate;
return {
ok: true,
value: {
scriptId: "00000000-0000-4000-8000-000000000002",
sourceKind: "inline",
sourcePath: null,
sourceSha256: "a".repeat(64),
messages: [{ type: "send", payload: "cancel-observed" }],
messagesTruncated: false,
},
};
},
status(sessionId) {
return {
sessionId,
deviceId: "remote-device",
target: "fixture",
pid: 10,
mode: "attach",
state: "running",
targetResumeObserved: false,
scripts: [],
messages: [{ type: "send", payload: "cancel-observed" }],
messagesTruncated: false,
};
},
async closeSession() {
markSessionClosed?.();
return { ok: true, value: { targetResumeObserved: false } };
},
});
const server = createServer(
{ kind: "session", session },
{ fridaInstrumentation: provider },
);
const client = new Client({ name: "frida-cancellation", version: "1" });
const controller = new AbortController();
onTestFinished(async () => {
finishLoad?.();
controller.abort();
await client.close();
await server.close();
await session.close();
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const call = client.callTool(
{
name: "instrument_with_frida",
arguments: {
mode: "attach",
pid: 10,
source_kind: "inline",
source: "send('cancel-observed');",
duration_ms: 1_000,
},
},
{ signal: controller.signal },
);
await loadStarted;
controller.abort(new Error("cancel Frida observation"));
finishLoad?.();
await expect(call).rejects.toThrow("cancel Frida observation");
await sessionClosed;
await client.ping();
const bundle = await client.callTool({
name: "get_evidence_bundle",
arguments: {},
});
expect(JSON.stringify(bundle.structuredContent)).toContain("cancel-observed");
expect(JSON.stringify(bundle.structuredContent)).toContain(
"instrument_with_frida",
);
});

View File

@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"files": [
"src/frida/frida-bindings.d.ts",
"scripts/verify/fridaBindingsCompatibility.ts"
],
"include": []
}