@mmerterden/multi-agent-toolkit-mcp 3.11.0 → 3.12.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 +70 -0
- package/index.js +124 -18
- package/package.json +7 -1
- package/tools/ios-app-store-audit/context.js +160 -9
- package/tools/ios-app-store-audit/index.js +24 -1
- package/tools/offload/index.js +28 -8
package/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,76 @@ Releases before this file exists are recorded in the git tags and commit history
|
|
|
15
15
|
|
|
16
16
|
---
|
|
17
17
|
|
|
18
|
+
## Unreleased
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 3.12.0
|
|
23
|
+
|
|
24
|
+
The CI matrix had been red for five runs. Every fix below is something the red
|
|
25
|
+
leg was pointing at; three of them are defects a green local run could not see,
|
|
26
|
+
because the thing that broke was the assumption that the host is a Mac.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **`ios_app_store_audit` answered PASS on an archive it never read.** Plist
|
|
31
|
+
parsing went entirely through `plutil`, a macOS binary. Off macOS every read
|
|
32
|
+
returned `null`, all 18 rules saw an empty archive, found nothing, and the
|
|
33
|
+
verdict came back clean - on a fixture built to be non-compliant. There are
|
|
34
|
+
now three answers instead of two: `plutil` when it is there, a small XML plist
|
|
35
|
+
reader when it is not, and an explicit record of what could be read by
|
|
36
|
+
neither. An audit with a non-empty `unreadablePlists` reports
|
|
37
|
+
`measurable: false` and an `archive-readable` error, so it can no longer
|
|
38
|
+
return PASS. An unreadable archive and a compliant one now look different.
|
|
39
|
+
- **Argument rules were unreachable on a host without Xcode.** `handleIOS`
|
|
40
|
+
refused everything with "Xcode not installed" before any handler ran, so the
|
|
41
|
+
checks that answer from the arguments alone - `extra_args` shell
|
|
42
|
+
metacharacters, `project` XOR `workspace`, `mode=diff` without
|
|
43
|
+
`baseline_graph`, `ios_leaks` with neither `pid` nor `bundle_id`,
|
|
44
|
+
`ios_testflight_validate` with neither `ipa_path` nor `list_providers` - never
|
|
45
|
+
ran there, and the tests that prove them could not run either. They are
|
|
46
|
+
evaluated before the capability gate now, so a malformed call is named as
|
|
47
|
+
malformed on any host. The `extra_args` character class is additionally
|
|
48
|
+
declared as `pattern` on the tool schema, where a host can enforce it too.
|
|
49
|
+
- **`build-stack-plugins.mjs`-style "works on the author's machine" in the
|
|
50
|
+
test harness.** Gate 10 printed a bare exit code when a suite failed, so a red
|
|
51
|
+
CI leg said which file failed and nothing about why. It now prints the failing
|
|
52
|
+
assertions, matching both reporter shapes `node:test` uses (TAP when piped,
|
|
53
|
+
spec on a terminal).
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
|
|
57
|
+
- **`validateArgs` enforces the whole schema, not three keywords of it.** It
|
|
58
|
+
checked `type`, `required` and `enum`; `pattern`, `minimum`, `maximum`,
|
|
59
|
+
`minLength`, `maxLength`, `minItems`, `maxItems` and `items` were declarable
|
|
60
|
+
and unenforced. A schema the server does not keep is worse than no schema,
|
|
61
|
+
because the host shows the caller a contract that is not real. Array `items`
|
|
62
|
+
are validated element by element, including nested `required` and property
|
|
63
|
+
types. No existing tool declared any of the newly-enforced keywords, so
|
|
64
|
+
nothing that used to be accepted is now refused.
|
|
65
|
+
- **`design-check` tests are `node:test` cases.** 1002 lines and 143 assertions
|
|
66
|
+
ran inside a hand-rolled tally that ended in `process.exit(1)`: the runner saw
|
|
67
|
+
one opaque suite, coverage could not attribute a line, and a failure named the
|
|
68
|
+
file rather than the check. Same assertions, now 143 reported cases. The two
|
|
69
|
+
that need `ripgrep` register as skips with a reason instead of printing a
|
|
70
|
+
warning nobody reads.
|
|
71
|
+
- **Dependency advisories closed by upgrade, not by allowlist.** `fast-uri`,
|
|
72
|
+
`hono` and `qs` reach this tree only through `@modelcontextprotocol/sdk`;
|
|
73
|
+
bounded `overrides` resolve all three. `scripts/audit-allowlist.mjs` now has
|
|
74
|
+
an empty accepted list, and its stdio-only assertion runs on every invocation
|
|
75
|
+
rather than only when an advisory happens to be open.
|
|
76
|
+
- **Repo tooling only, nothing in the published package.** A `PostToolUse` hook
|
|
77
|
+
(`.claude/settings.json` -> `scripts/hooks/stdout-hygiene.sh`) greps the file
|
|
78
|
+
that was just edited for `console.log` under `index.js` and `tools/`, and
|
|
79
|
+
fails the edit back to the author when it finds one. stdout is this server's
|
|
80
|
+
JSON-RPC channel, so a stray log corrupts the protocol stream and the client
|
|
81
|
+
reports only a closed connection. Gate 5 in `scripts/gates.sh` remains the
|
|
82
|
+
authority - it decides whether the package ships; the hook only moves the
|
|
83
|
+
catch from ship-day to edit-second, and gate 5 now also asserts that the hook
|
|
84
|
+
exists, parses and is wired.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
18
88
|
## 3.11.0
|
|
19
89
|
|
|
20
90
|
### Added
|
package/index.js
CHANGED
|
@@ -484,7 +484,7 @@ const IOS_TOOLS = [
|
|
|
484
484
|
{ name: "ios_export_ipa", description: "Export a .xcarchive to a signed .ipa via xcodebuild -exportArchive. Generates the exportOptions.plist from the arguments (method defaults to app-store-connect), so callers do not have to hand-maintain one. Returns the .ipa path plus parsed errors; a run that exits 0 without producing an .ipa is reported as a failure. Pair with ios_testflight_validate for the pre-submission gate.", inputSchema: { type: "object", properties: { archive_path: { type: "string", description: "Absolute path to the .xcarchive" }, output_dir: { type: "string", description: "Directory to write the .ipa into" }, method: { type: "string", description: "Export method: app-store-connect (default) | release-testing | enterprise | development" }, team_id: { type: "string", description: "Apple Developer team ID" }, provisioning_profiles: { type: "object", description: "Map of bundleId -> provisioning profile name (manual signing)" }, signing_style: { type: "string", description: "automatic | manual" }, upload_symbols: { type: "boolean", description: "Include symbols (default true)" }, allow_provisioning_updates: { type: "boolean", description: "Off by default. Lets xcodebuild register devices and create/modify provisioning profiles in the developer account - a change on Apple's side, so it is opt-in" }, timeout_sec: { type: "number", description: "Default 900" } }, required: ["archive_path", "output_dir"] } },
|
|
485
485
|
{ name: "ios_testflight_validate", description: "Run Apple's own pre-submission validation on an .ipa via `xcrun altool --validate-app`, then map returned ITMS error codes onto the App Store rule each one implies. This is the authoritative gate: unlike the static ios_app_store_audit it can catch an unregistered bundle ID, a profile that does not match the App Store Connect app record, a version+build pair already used, and entitlements not provisioned for the app ID. Auth is a 3-tier chain: ASC API key (api_key_id + api_issuer_id), else Apple ID + app-specific password referenced indirectly through a keychain item or env var (never passed by value), else the gate returns verdict SKIPPED with a reason. SKIPPED is not a pass - it means Apple was never asked. Set list_providers=true for a pre-flight that reports which teams the credentials can deliver for (needed when a corporate Apple ID belongs to several).", inputSchema: { type: "object", properties: { ipa_path: { type: "string", description: "Absolute path to the .ipa" }, platform: { type: "string", description: "ios (default) | appletvos | visionos | macos" }, api_key_id: { type: "string", description: "App Store Connect API key ID (tier 1)" }, api_issuer_id: { type: "string", description: "App Store Connect issuer ID (tier 1)" }, p8_path: { type: "string", description: "Explicit path to AuthKey_<id>.p8; otherwise altool's search dirs are used" }, apple_id: { type: "string", description: "Apple ID (tier 2)" }, keychain_item: { type: "string", description: "Keychain item holding the app-specific password (tier 2, preferred)" }, password_env_var: { type: "string", description: "Env var holding the app-specific password (tier 2 fallback)" }, provider_public_id: { type: "string", description: "Required when the account belongs to multiple providers" }, list_providers: { type: "boolean", description: "Pre-flight only: list deliverable providers and return" }, timeout_sec: { type: "number", description: "Default 900" } }, required: [] } },
|
|
486
486
|
{ name: "ios_app_store_audit", description: "Deep App Store Review compliance audit for .xcarchive bundles. 18-rule catalog covering privacy manifest, code signing, embedded SDKs, entitlements, asset hygiene, IPv6 compliance, debug-tool leak detection, Swift ABI compatibility, SDK floor (ITMS-90725), and more. Returns structured JSON with severity-ranked violations and ITMS error code mappings. Replaces ios_archive_audit (deprecated). Pass rules='core' for the 6 baseline checks (code-signing, entitlements, info-plist, privacy-manifest, binary-size, sdk-floor); 'all'|'deep' for the full 18-rule scan; CSV like 'binary-size,ipv6-compliance' for an explicit subset.", inputSchema: { type: "object", properties: { archive_path: { type: "string", description: "Absolute path to the .xcarchive bundle" }, rules: { type: "string", description: "'all' (default) | 'core' | 'deep' | comma-separated ruleIDs" } }, required: ["archive_path"] } },
|
|
487
|
-
{ name: "ios_xcodebuild", description: "Build / test / clean an Xcode project with progressive disclosure. Returns one-line summary plus xcresult ID; drill in via ios_xcresult. Token-efficient - full log stays out of context unless requested.", inputSchema: { type: "object", properties: { project: { type: "string", description: "Path to .xcodeproj (mutually exclusive with workspace)" }, workspace: { type: "string", description: "Path to .xcworkspace (mutually exclusive with project)" }, scheme: { type: "string" }, configuration: { type: "string", description: "Debug / Release (default: Release)" }, destination: { type: "string", description: "Xcode destination string. Default: generic iOS Simulator; for action test, the booted simulator (xcodebuild refuses a generic destination for test)" }, action: { type: "string", enum: ["build", "test", "clean", "archive", "clean-build"], description: "Default: build" }, derived_data_path: { type: "string" }, extra_args: { type: "string", description: "Additional raw xcodebuild args appended verbatim" }, timeout_sec: { type: "number", description: "Build timeout in seconds (default 600)" } }, required: ["scheme"] } },
|
|
487
|
+
{ name: "ios_xcodebuild", description: "Build / test / clean an Xcode project with progressive disclosure. Returns one-line summary plus xcresult ID; drill in via ios_xcresult. Token-efficient - full log stays out of context unless requested.", inputSchema: { type: "object", properties: { project: { type: "string", description: "Path to .xcodeproj (mutually exclusive with workspace)" }, workspace: { type: "string", description: "Path to .xcworkspace (mutually exclusive with project)" }, scheme: { type: "string" }, configuration: { type: "string", description: "Debug / Release (default: Release)" }, destination: { type: "string", description: "Xcode destination string. Default: generic iOS Simulator; for action test, the booted simulator (xcodebuild refuses a generic destination for test)" }, action: { type: "string", enum: ["build", "test", "clean", "archive", "clean-build"], description: "Default: build" }, derived_data_path: { type: "string" }, extra_args: { type: "string", description: "Additional raw xcodebuild args appended verbatim. Plain flags and values only - shell metacharacters are refused.", pattern: "^[^;&|`$(){}<>\\\\\\n]*$", patternHint: "may contain only plain flags and values (no shell metacharacters ; & | ` $ ( ) { } < > \\\\)" }, timeout_sec: { type: "number", description: "Build timeout in seconds (default 600)" } }, required: ["scheme"] } },
|
|
488
488
|
{ name: "ios_xcresult", description: "Drill into a previous ios_xcodebuild result by xcresult ID. Modes: summary (counts), errors (file:line + message), warnings, log (last N lines), tests (failed), metrics (XCTMetric performance results as JSON). Use this instead of dumping the whole build log into context.", inputSchema: { type: "object", properties: { id: { type: "string", description: "xcresult ID returned by ios_xcodebuild" }, mode: { type: "string", enum: ["summary", "errors", "warnings", "log", "tests", "metrics"], description: "Default: summary" }, log_lines: { type: "number", description: "Lines of raw log to return when mode=log (default 200)" }, test_id: { type: "string", description: "mode=metrics only: scope to one test case or suite instead of every measured test" } }, required: ["id"] } },
|
|
489
489
|
{ name: "ios_visual_diff", description: "Compare two PNG screenshots. Returns JSON with diff_pct, pass/fail vs threshold, and an optional diff image. Use for snapshot regression checks across light/dark, locale, dynamic type variants.", inputSchema: { type: "object", properties: { baseline: { type: "string", description: "Path to baseline PNG" }, current: { type: "string", description: "Path to current PNG" }, threshold: { type: "number", description: "Per-pixel color threshold 0..1 (default 0.1, lower = stricter)" }, max_diff_pct: { type: "number", description: "Fail if diff exceeds this percent (default 1.0)" }, output: { type: "string", description: "Path to write diff PNG (optional)" } }, required: ["baseline", "current"] } },
|
|
490
490
|
{ name: "ios_leaks", description: "Look for leaked memory in a running simulator (or host) process with /usr/bin/leaks. mode=snapshot reports the current leak count and bytes; mode=diff reports only leaks new since a saved memory graph, which is the shape a regression gate wants. Reports measurable:false when the target lacks get-task-allow rather than reporting it as clean - leaks exits 0 in that case, so an unmeasurable target and a clean one are indistinguishable by exit status. Debug builds are debuggable; Apple-signed apps are not.", inputSchema: { type: "object", properties: { pid: { type: "number", description: "Process id. Either this or bundle_id." }, bundle_id: { type: "string", description: "Bundle id of an app running on the booted simulator; its pid is resolved for you." }, device_id: { type: "string" }, mode: { type: "string", enum: ["snapshot", "diff"], description: "Default: snapshot" }, baseline_graph: { type: "string", description: "mode=diff: path to the memory graph saved by an earlier call" }, output_graph: { type: "string", description: "Save a memory graph here to use as a later baseline" } }, required: [] } },
|
|
@@ -496,7 +496,38 @@ const IOS_TOOLS = [
|
|
|
496
496
|
// reports needs no simulator, so a missing Xcode must not refuse them.
|
|
497
497
|
const IOS_TOOLS_WITHOUT_XCRUN = new Set(["ios_visual_diff", "ios_list_crashes", "ios_xcresult"]);
|
|
498
498
|
|
|
499
|
+
// Argument rules a JSON Schema cannot state, because they relate two fields
|
|
500
|
+
// rather than constrain one. They ran inside each handler, which put them
|
|
501
|
+
// BEHIND the "Xcode not installed" gate: on a host without Xcode every
|
|
502
|
+
// malformed call got the same answer as a well-formed one, and the tests that
|
|
503
|
+
// prove these rules could not run there at all. That is how the Linux CI leg
|
|
504
|
+
// found them. They answer from the arguments alone, so they belong in front of
|
|
505
|
+
// the capability gate - a caller learns their call is wrong whether or not this
|
|
506
|
+
// machine could have run it.
|
|
507
|
+
const IOS_ARG_RULES = {
|
|
508
|
+
ios_leaks: (a) => {
|
|
509
|
+
if (!a.pid && !a.bundle_id) return "pass pid or bundle_id";
|
|
510
|
+
if ((a.mode || "snapshot") === "diff" && !a.baseline_graph) {
|
|
511
|
+
return "mode=diff needs baseline_graph, the memory graph saved by an earlier call";
|
|
512
|
+
}
|
|
513
|
+
return null;
|
|
514
|
+
},
|
|
515
|
+
ios_xcodebuild: (a) => {
|
|
516
|
+
if (!a.project && !a.workspace) return "project or workspace required";
|
|
517
|
+
if (a.project && a.workspace) return "pass project OR workspace, not both";
|
|
518
|
+
return null;
|
|
519
|
+
},
|
|
520
|
+
ios_accessibility_audit_deep: (a) => (!a.project && !a.workspace ? "pass project or workspace" : null),
|
|
521
|
+
ios_testflight_validate: (a) =>
|
|
522
|
+
!a.list_providers && !a.ipa_path ? "ipa_path is required (or pass list_providers=true)" : null,
|
|
523
|
+
};
|
|
524
|
+
|
|
499
525
|
async function handleIOS(name, args, ctx = {}) {
|
|
526
|
+
const argRule = IOS_ARG_RULES[name];
|
|
527
|
+
if (argRule) {
|
|
528
|
+
const bad = argRule(args);
|
|
529
|
+
if (bad) return `${ERROR_PREFIX}${bad}`;
|
|
530
|
+
}
|
|
500
531
|
if (!HAS_XCRUN && !IOS_TOOLS_WITHOUT_XCRUN.has(name)) return `${ERROR_PREFIX}Xcode not installed - iOS tools unavailable. Install Xcode and run: xcode-select --install`;
|
|
501
532
|
const did = (n) => { try { return iosDevice(n); } catch (e) { return null; } };
|
|
502
533
|
|
|
@@ -798,7 +829,6 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
798
829
|
const res = await listProviders(auth, ctx.signal);
|
|
799
830
|
return JSON.stringify({ authTier: auth.tier, authMethod: auth.method, ...res }, null, 2);
|
|
800
831
|
}
|
|
801
|
-
if (!args.ipa_path) return "ERROR: ipa_path is required (or pass list_providers=true)";
|
|
802
832
|
// Same finally rationale as ios_export_ipa: never leak the heartbeat.
|
|
803
833
|
const stopHeartbeat = startHeartbeat(ctx, "altool --validate-app");
|
|
804
834
|
let res;
|
|
@@ -831,8 +861,6 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
831
861
|
}
|
|
832
862
|
case "ios_xcodebuild": {
|
|
833
863
|
if (!HAS_XCRUN) return "ERROR: xcrun not available - Xcode Command Line Tools required";
|
|
834
|
-
if (!args.project && !args.workspace) return "ERROR: project or workspace required";
|
|
835
|
-
if (args.project && args.workspace) return "ERROR: pass project OR workspace, not both";
|
|
836
864
|
const action = args.action || "build";
|
|
837
865
|
const config = args.configuration || "Release";
|
|
838
866
|
let dest = args.destination;
|
|
@@ -852,9 +880,13 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
852
880
|
const derivedFlag = args.derived_data_path ? `-derivedDataPath ${shq(args.derived_data_path)}` : "";
|
|
853
881
|
const actionMap = { "clean-build": "clean build", build: "build", test: "test", clean: "clean", archive: "archive" };
|
|
854
882
|
// extra_args is appended verbatim (multiple flags, so it can't be a single
|
|
855
|
-
// shq'd token), which makes it a shell passthrough.
|
|
856
|
-
//
|
|
857
|
-
//
|
|
883
|
+
// shq'd token), which makes it a shell passthrough. The same character class
|
|
884
|
+
// is declared as `pattern` on the tool schema, so the CallTool boundary
|
|
885
|
+
// refuses the payload before dispatch; this is the second line, kept because
|
|
886
|
+
// the string is one concatenation away from a shell. It lived only here
|
|
887
|
+
// until the Linux CI run showed why that was not enough: handleIOS returns
|
|
888
|
+
// "Xcode not installed" first, so on any host without Xcode the guard was
|
|
889
|
+
// never reached and the test that proves it works could not run.
|
|
858
890
|
const extra = args.extra_args || "";
|
|
859
891
|
if (/[;&|`$(){}<>\n\\]/.test(extra)) {
|
|
860
892
|
return "ERROR: extra_args may contain only plain flags and values (no shell metacharacters ; & | ` $ ( ) { } < > \\).";
|
|
@@ -980,7 +1012,6 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
980
1012
|
const mode = args.mode || "snapshot";
|
|
981
1013
|
const graphOut = args.output_graph ? ` --outputGraph=${shq(args.output_graph)}` : "";
|
|
982
1014
|
if (mode === "diff") {
|
|
983
|
-
if (!args.baseline_graph) return "ERROR: mode=diff needs baseline_graph, the memory graph saved by an earlier call";
|
|
984
1015
|
if (!existsSync(args.baseline_graph)) return `ERROR: baseline graph not found at ${args.baseline_graph}`;
|
|
985
1016
|
}
|
|
986
1017
|
const diffArg = mode === "diff" ? ` --diffFrom=${shq(args.baseline_graph)}` : "";
|
|
@@ -1007,7 +1038,6 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
1007
1038
|
}, null, 2);
|
|
1008
1039
|
}
|
|
1009
1040
|
case "ios_accessibility_audit_deep": {
|
|
1010
|
-
if (!args.project && !args.workspace) return "ERROR: pass project or workspace";
|
|
1011
1041
|
const container = args.workspace ? `-workspace ${shq(args.workspace)}` : `-project ${shq(args.project)}`;
|
|
1012
1042
|
const d = iosDevice(args.device_id);
|
|
1013
1043
|
const bundle = join(SCREENSHOT_DIR, `a11ydeep_${Date.now()}.xcresult`);
|
|
@@ -1354,17 +1384,29 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
1354
1384
|
}
|
|
1355
1385
|
// 2. Signing check. apksigner exits 1 on a failed verification with the
|
|
1356
1386
|
// verdict on stdout, so the output is captured whatever the status.
|
|
1387
|
+
//
|
|
1388
|
+
// "signed" needs positive evidence - apksigner's own `Verifies` line or a
|
|
1389
|
+
// printed certificate. The branch used to be the else of three negative
|
|
1390
|
+
// tests, so ANY output it did not recognise became "APK is signed": on a
|
|
1391
|
+
// host with apksigner installed, 1KB of zero bytes audited as a correctly
|
|
1392
|
+
// signed APK. It read as correct on a Mac only because apksigner is not
|
|
1393
|
+
// there, and "command not found" is one of the strings it did recognise.
|
|
1357
1394
|
const signingInfo = runCapture(`apksigner verify --print-certs ${shq(p)} 2>&1`);
|
|
1395
|
+
const verifies = /^Verifies\b/m.test(signingInfo) || /Signer #\d+ certificate DN:/.test(signingInfo);
|
|
1358
1396
|
if (/DOES NOT VERIFY/.test(signingInfo)) {
|
|
1359
1397
|
findings.push({ check: "signing", status: "critical", detail: signingInfo.slice(0, 500) });
|
|
1360
1398
|
} else if (isFailure(signingInfo) || /command not found|No such file or directory/i.test(signingInfo)) {
|
|
1361
1399
|
findings.push({ check: "signing", status: "warning", detail: "apksigner not found - install Android SDK Build-Tools to verify the signature" });
|
|
1362
|
-
} else if (
|
|
1363
|
-
findings.push({ check: "signing", status: "warning", detail: signingInfo.slice(0, 500) });
|
|
1364
|
-
} else {
|
|
1400
|
+
} else if (verifies) {
|
|
1365
1401
|
const hasV2 = signingInfo.includes("v2 scheme") || (runOrNull(`apksigner verify -v ${shq(p)} 2>&1`) || "").includes("Verified using v2");
|
|
1366
1402
|
findings.push({ check: "signing", status: "pass", detail: "APK is signed" });
|
|
1367
1403
|
findings.push({ check: "signing_v2", status: hasV2 ? "pass" : "warning", detail: hasV2 ? "v2+ signature present" : "Only v1 signature - consider v2+ for tamper protection" });
|
|
1404
|
+
} else {
|
|
1405
|
+
findings.push({
|
|
1406
|
+
check: "signing",
|
|
1407
|
+
status: "warning",
|
|
1408
|
+
detail: `apksigner did not report a verified signature; treat this as unverified, not as signed: ${signingInfo.slice(0, 400) || "(no output)"}`,
|
|
1409
|
+
});
|
|
1368
1410
|
}
|
|
1369
1411
|
// 3. File size
|
|
1370
1412
|
try {
|
|
@@ -1701,6 +1743,74 @@ function schemaTypeOk(value, type) {
|
|
|
1701
1743
|
}
|
|
1702
1744
|
}
|
|
1703
1745
|
|
|
1746
|
+
// Constraint keywords beyond type/required/enum. A schema that declares
|
|
1747
|
+
// `pattern` or `maximum` and has nobody enforcing it is worse than one that
|
|
1748
|
+
// declares nothing: the host shows the caller a contract the server does not
|
|
1749
|
+
// keep. Everything a tool literal is allowed to write is checked here.
|
|
1750
|
+
//
|
|
1751
|
+
// `patternHint` is ours, not JSON Schema. A regex in an error message tells a
|
|
1752
|
+
// human nothing; the hint says what the rule is in words. Hosts ignore unknown
|
|
1753
|
+
// keywords, so it rides along in inputSchema harmlessly.
|
|
1754
|
+
function checkConstraints(key, value, spec) {
|
|
1755
|
+
if (spec.type && !schemaTypeOk(value, spec.type)) {
|
|
1756
|
+
return `argument '${key}' must be ${spec.type}, got ${Array.isArray(value) ? "array" : typeof value}`;
|
|
1757
|
+
}
|
|
1758
|
+
if (Array.isArray(spec.enum) && !spec.enum.includes(value)) {
|
|
1759
|
+
return `argument '${key}' must be one of ${JSON.stringify(spec.enum)}`;
|
|
1760
|
+
}
|
|
1761
|
+
if (typeof spec.pattern === "string" && !new RegExp(spec.pattern).test(String(value))) {
|
|
1762
|
+
return spec.patternHint
|
|
1763
|
+
? `argument '${key}' ${spec.patternHint}`
|
|
1764
|
+
: `argument '${key}' must match ${spec.pattern}`;
|
|
1765
|
+
}
|
|
1766
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
1767
|
+
const str = String(value);
|
|
1768
|
+
if (Number.isFinite(spec.minLength) && str.length < spec.minLength) {
|
|
1769
|
+
return `argument '${key}' must be at least ${spec.minLength} characters`;
|
|
1770
|
+
}
|
|
1771
|
+
if (Number.isFinite(spec.maxLength) && str.length > spec.maxLength) {
|
|
1772
|
+
return `argument '${key}' must be at most ${spec.maxLength} characters`;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
if (spec.type === "number" || spec.type === "integer") {
|
|
1776
|
+
const num = Number(value);
|
|
1777
|
+
if (Number.isFinite(spec.minimum) && num < spec.minimum) {
|
|
1778
|
+
return `argument '${key}' must be >= ${spec.minimum}`;
|
|
1779
|
+
}
|
|
1780
|
+
if (Number.isFinite(spec.maximum) && num > spec.maximum) {
|
|
1781
|
+
return `argument '${key}' must be <= ${spec.maximum}`;
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
if (Array.isArray(value)) {
|
|
1785
|
+
if (Number.isFinite(spec.minItems) && value.length < spec.minItems) {
|
|
1786
|
+
return `argument '${key}' must have at least ${spec.minItems} item(s)`;
|
|
1787
|
+
}
|
|
1788
|
+
if (Number.isFinite(spec.maxItems) && value.length > spec.maxItems) {
|
|
1789
|
+
return `argument '${key}' must have at most ${spec.maxItems} item(s)`;
|
|
1790
|
+
}
|
|
1791
|
+
if (spec.items) {
|
|
1792
|
+
for (let i = 0; i < value.length; i++) {
|
|
1793
|
+
const err = checkConstraints(`${key}[${i}]`, value[i], spec.items);
|
|
1794
|
+
if (err) return err;
|
|
1795
|
+
for (const req of spec.items.required || []) {
|
|
1796
|
+
const el = value[i];
|
|
1797
|
+
if (el === null || typeof el !== "object" || el[req] === undefined || el[req] === null) {
|
|
1798
|
+
return `argument '${key}[${i}]' is missing required field: ${req}`;
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
for (const [k, sub] of Object.entries(spec.items.properties || {})) {
|
|
1802
|
+
const el = value[i];
|
|
1803
|
+
if (el === null || typeof el !== "object") continue;
|
|
1804
|
+
if (el[k] === undefined || el[k] === null) continue;
|
|
1805
|
+
const subErr = checkConstraints(`${key}[${i}].${k}`, el[k], sub);
|
|
1806
|
+
if (subErr) return subErr;
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
return null;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1704
1814
|
function validateArgs(name, args) {
|
|
1705
1815
|
const schema = TOOL_SCHEMAS.get(name);
|
|
1706
1816
|
if (!schema || schema.type !== "object") return null;
|
|
@@ -1712,12 +1822,8 @@ function validateArgs(name, args) {
|
|
|
1712
1822
|
}
|
|
1713
1823
|
for (const [key, spec] of Object.entries(props)) {
|
|
1714
1824
|
if (args[key] === undefined || args[key] === null) continue;
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
}
|
|
1718
|
-
if (Array.isArray(spec.enum) && !spec.enum.includes(args[key])) {
|
|
1719
|
-
return `argument '${key}' must be one of ${JSON.stringify(spec.enum)}`;
|
|
1720
|
-
}
|
|
1825
|
+
const err = checkConstraints(key, args[key], spec);
|
|
1826
|
+
if (err) return err;
|
|
1721
1827
|
}
|
|
1722
1828
|
return null;
|
|
1723
1829
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmerterden/multi-agent-toolkit-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.12.0",
|
|
4
4
|
"description": "MCP server for iOS Simulator, Android Emulator and headless web control. 99 tools: device automation (tap/swipe/type), accessibility audits, visual diff, crash logs, App Store / Play Store pre-submission compliance. Runs standalone over stdio with any MCP client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -73,6 +73,12 @@
|
|
|
73
73
|
"CHANGELOG.md",
|
|
74
74
|
"LICENSE"
|
|
75
75
|
],
|
|
76
|
+
"_overridesReadme": "Three advisories reach this tree only through @modelcontextprotocol/sdk, and all three are fixed inside the major their consumer already depends on. The upper bound on fast-uri is load-bearing: ajv@8 declares ^3, and an unbounded >=3.1.6 resolves to 4.x, which is a different major under a dependency that never asked for it.",
|
|
77
|
+
"overrides": {
|
|
78
|
+
"fast-uri": ">=3.1.6 <4",
|
|
79
|
+
"hono": ">=4.13.5 <5",
|
|
80
|
+
"qs": ">=6.16.0 <7"
|
|
81
|
+
},
|
|
76
82
|
"dependencies": {
|
|
77
83
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
78
84
|
"pixelmatch": "^7.2.0",
|
|
@@ -3,14 +3,22 @@
|
|
|
3
3
|
* 18 rules consume. Direct port of XCArchiveParser.swift +
|
|
4
4
|
* InfoPlistParser.swift + PrivacyManifestParser.swift + EntitlementParser.swift.
|
|
5
5
|
*
|
|
6
|
-
* Plist parsing
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Plist parsing prefers `plutil -convert json` (system binary on macOS, ships
|
|
7
|
+
* with the OS) - more reliable than any Node `plist` package across the long
|
|
8
|
+
* tail of Apple plist quirks (binary plists, XML-with-DTD, mixed array/dict
|
|
9
|
+
* roots).
|
|
10
|
+
*
|
|
11
|
+
* When plutil is absent it falls back to a small XML reader, and when even that
|
|
12
|
+
* cannot apply - a binary plist off macOS - it records the miss rather than
|
|
13
|
+
* returning an empty object. That distinction is the whole point: every rule
|
|
14
|
+
* reads `infoPlist`, so a silent `{}` makes an unreadable archive audit exactly
|
|
15
|
+
* like a compliant one, and the tool answers PASS because it saw nothing. It
|
|
16
|
+
* shipped that way; a run with no plutil on PATH reported zero violations on a
|
|
17
|
+
* deliberately non-compliant fixture.
|
|
10
18
|
*/
|
|
11
19
|
|
|
12
20
|
import { execSync } from "child_process";
|
|
13
|
-
import { existsSync, readdirSync, statSync } from "fs";
|
|
21
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
14
22
|
import { shq } from "./models.js";
|
|
15
23
|
import { join, basename } from "path";
|
|
16
24
|
|
|
@@ -23,16 +31,159 @@ function run(cmd) {
|
|
|
23
31
|
}
|
|
24
32
|
}
|
|
25
33
|
|
|
26
|
-
/**
|
|
34
|
+
/** True when the `plutil` binary is on PATH. Probed once. */
|
|
35
|
+
let _hasPlutil = null;
|
|
36
|
+
export function hasPlutil() {
|
|
37
|
+
if (_hasPlutil === null) _hasPlutil = run("command -v plutil") !== "";
|
|
38
|
+
return _hasPlutil;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Plists this process could not read, as absolute paths. A rule that finds
|
|
43
|
+
* nothing in an unread plist is not evidence of compliance, and runAudit turns
|
|
44
|
+
* a non-empty list into a finding rather than letting the verdict stand.
|
|
45
|
+
*/
|
|
46
|
+
export const unreadablePlists = new Set();
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Empty the record. runAudit calls this before it parses anything, so the set
|
|
50
|
+
* describes the run being reported and not every run this process has done.
|
|
51
|
+
*/
|
|
52
|
+
export function resetUnreadablePlists() {
|
|
53
|
+
unreadablePlists.clear();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Decode the XML entities a plist can carry. */
|
|
57
|
+
function unescapeXml(s) {
|
|
58
|
+
return s
|
|
59
|
+
.replace(/</g, "<")
|
|
60
|
+
.replace(/>/g, ">")
|
|
61
|
+
.replace(/"/g, '"')
|
|
62
|
+
.replace(/'/g, "'")
|
|
63
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCharCode(Number(d)))
|
|
64
|
+
.replace(/&/g, "&");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Minimal XML-plist reader for hosts without plutil.
|
|
69
|
+
*
|
|
70
|
+
* Deliberately small: it handles the element set an Info.plist, an entitlements
|
|
71
|
+
* file and a PrivacyInfo.xcprivacy actually use. It is not a general plist
|
|
72
|
+
* implementation and makes no attempt at binary plists - those return null and
|
|
73
|
+
* are recorded as unreadable, which is the honest answer.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} xml
|
|
76
|
+
* @returns {object|Array|null}
|
|
77
|
+
*/
|
|
78
|
+
export function parseXmlPlist(xml) {
|
|
79
|
+
// <!DOCTYPE ...> and <?xml ...?> carry nothing we need and complicate the scan.
|
|
80
|
+
const body = xml.replace(/<\?xml[^>]*\?>/g, "").replace(/<!DOCTYPE[^>]*>/g, "");
|
|
81
|
+
const tokens = body.match(/<\/?[A-Za-z]+(?:\s[^>]*)?\/?>|[^<]+/g);
|
|
82
|
+
if (!tokens) return null;
|
|
83
|
+
let i = 0;
|
|
84
|
+
|
|
85
|
+
const next = () => tokens[i++];
|
|
86
|
+
const peek = () => tokens[i];
|
|
87
|
+
|
|
88
|
+
function readValue(tag) {
|
|
89
|
+
switch (tag) {
|
|
90
|
+
case "dict": {
|
|
91
|
+
const out = {};
|
|
92
|
+
for (;;) {
|
|
93
|
+
const t = peek();
|
|
94
|
+
if (t === undefined || t === "</dict>") {
|
|
95
|
+
next();
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
if (t === "<key>") {
|
|
99
|
+
next();
|
|
100
|
+
const k = unescapeXml(String(next() ?? "").trim());
|
|
101
|
+
next(); // </key>
|
|
102
|
+
const vt = String(next() ?? "").trim();
|
|
103
|
+
out[k] = readValue(vt.replace(/^<|\/?>$/g, "").split(/\s/)[0]);
|
|
104
|
+
} else {
|
|
105
|
+
next();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
case "array": {
|
|
110
|
+
const out = [];
|
|
111
|
+
for (;;) {
|
|
112
|
+
const t = peek();
|
|
113
|
+
if (t === undefined || t === "</array>") {
|
|
114
|
+
next();
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
const vt = String(next() ?? "").trim();
|
|
118
|
+
if (!vt.startsWith("<")) continue;
|
|
119
|
+
out.push(readValue(vt.replace(/^<|\/?>$/g, "").split(/\s/)[0]));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
case "string":
|
|
123
|
+
case "data":
|
|
124
|
+
case "date": {
|
|
125
|
+
const raw = peek() !== undefined && !String(peek()).startsWith("<") ? String(next()) : "";
|
|
126
|
+
if (String(peek() ?? "").startsWith("</")) next();
|
|
127
|
+
return unescapeXml(raw);
|
|
128
|
+
}
|
|
129
|
+
case "integer":
|
|
130
|
+
case "real": {
|
|
131
|
+
const raw = peek() !== undefined && !String(peek()).startsWith("<") ? String(next()) : "0";
|
|
132
|
+
if (String(peek() ?? "").startsWith("</")) next();
|
|
133
|
+
return Number(raw.trim());
|
|
134
|
+
}
|
|
135
|
+
case "true":
|
|
136
|
+
return true;
|
|
137
|
+
case "false":
|
|
138
|
+
return false;
|
|
139
|
+
default:
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (;;) {
|
|
145
|
+
const t = next();
|
|
146
|
+
if (t === undefined) return null;
|
|
147
|
+
const s = String(t).trim();
|
|
148
|
+
if (!s.startsWith("<plist")) continue;
|
|
149
|
+
for (;;) {
|
|
150
|
+
const u = next();
|
|
151
|
+
if (u === undefined) return null;
|
|
152
|
+
const v = String(u).trim();
|
|
153
|
+
if (!v.startsWith("<")) continue;
|
|
154
|
+
return readValue(v.replace(/^<|\/?>$/g, "").split(/\s/)[0]);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Parse a binary or XML plist file into a JS object. */
|
|
27
160
|
export function parsePlist(plistPath) {
|
|
28
161
|
if (!existsSync(plistPath)) return null;
|
|
29
|
-
|
|
30
|
-
|
|
162
|
+
if (hasPlutil()) {
|
|
163
|
+
const json = run(`plutil -convert json -o - ${shq(plistPath)} 2>/dev/null`);
|
|
164
|
+
if (json) {
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(json);
|
|
167
|
+
} catch {
|
|
168
|
+
/* fall through to the XML reader */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
let raw;
|
|
31
173
|
try {
|
|
32
|
-
|
|
174
|
+
raw = readFileSync(plistPath, "utf-8");
|
|
33
175
|
} catch {
|
|
176
|
+
unreadablePlists.add(plistPath);
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
// A binary plist starts with the magic "bplist"; there is no reading it here.
|
|
180
|
+
if (raw.startsWith("bplist")) {
|
|
181
|
+
unreadablePlists.add(plistPath);
|
|
34
182
|
return null;
|
|
35
183
|
}
|
|
184
|
+
const parsed = parseXmlPlist(raw);
|
|
185
|
+
if (parsed === null) unreadablePlists.add(plistPath);
|
|
186
|
+
return parsed;
|
|
36
187
|
}
|
|
37
188
|
|
|
38
189
|
/** Recursively measure total size of a directory in bytes. */
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* the rest of the scan.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { parseArchive } from "./context.js";
|
|
24
|
+
import { hasPlutil, parseArchive, resetUnreadablePlists, unreadablePlists } from "./context.js";
|
|
25
25
|
import { compareSeverity } from "./models.js";
|
|
26
26
|
|
|
27
27
|
// ---------- Rule registry --------------------------------------------------
|
|
@@ -93,6 +93,7 @@ function resolveRuleSelection(rules) {
|
|
|
93
93
|
* @returns {Promise<object>} structured audit report
|
|
94
94
|
*/
|
|
95
95
|
export async function runAudit({ archivePath, rules = "all", options = {} } = {}) {
|
|
96
|
+
resetUnreadablePlists();
|
|
96
97
|
const ctx = parseArchive(archivePath);
|
|
97
98
|
|
|
98
99
|
const selectedIDs = new Set(resolveRuleSelection(rules));
|
|
@@ -134,6 +135,25 @@ export async function runAudit({ archivePath, rules = "all", options = {} } = {}
|
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
// A rule that found nothing in a plist nobody could read has not measured
|
|
139
|
+
// anything, and the audit must not let that reach the caller as compliance.
|
|
140
|
+
// Every rule reads infoPlist, so one unread plist can silence all eighteen:
|
|
141
|
+
// the tool answered PASS on a deliberately non-compliant fixture the first
|
|
142
|
+
// time it ran on a host without plutil. Reported as an error so the verdict
|
|
143
|
+
// is not PASS, with the cause named - this is the tool's reach, not the
|
|
144
|
+
// archive's fault.
|
|
145
|
+
const unreadable = [...unreadablePlists].sort();
|
|
146
|
+
if (unreadable.length > 0) {
|
|
147
|
+
violations.push({
|
|
148
|
+
ruleID: "archive-readable",
|
|
149
|
+
severity: "error",
|
|
150
|
+
message: `${unreadable.length} plist(s) could not be read, so the rules below them measured nothing: ${unreadable.join(", ")}`,
|
|
151
|
+
suggestion: hasPlutil()
|
|
152
|
+
? "The files are present but neither plutil nor the XML reader could parse them. Re-export the archive, or open an issue with one of the files attached."
|
|
153
|
+
: "Binary plists need `plutil`, which ships with macOS. Run this audit on macOS, or convert the archive's plists to XML first (`plutil -convert xml1`).",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
137
157
|
// Sort: error → warning → info, then by ruleID alphabetically inside each.
|
|
138
158
|
violations.sort((a, b) => {
|
|
139
159
|
const c = compareSeverity(b.severity, a.severity);
|
|
@@ -156,6 +176,9 @@ export async function runAudit({ archivePath, rules = "all", options = {} } = {}
|
|
|
156
176
|
app: ctx.appName,
|
|
157
177
|
rulesRun: ranIDs,
|
|
158
178
|
rulesSkipped: skippedIDs,
|
|
179
|
+
// Explicit, so a caller can tell "clean" from "could not look".
|
|
180
|
+
measurable: unreadable.length === 0,
|
|
181
|
+
unreadablePlists: unreadable,
|
|
159
182
|
summary,
|
|
160
183
|
verdict,
|
|
161
184
|
violations,
|
package/tools/offload/index.js
CHANGED
|
@@ -58,15 +58,35 @@ function pruneEntries(dir, { keepFiles, keepDays, now, keep, match }) {
|
|
|
58
58
|
}
|
|
59
59
|
})
|
|
60
60
|
.filter(Boolean)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
61
|
+
// Name as the tie-break, because mtime ties are not a corner case. APFS
|
|
62
|
+
// records nanoseconds, so five files written in a loop have five distinct
|
|
63
|
+
// stamps; ext4 on a CI runner records one, so they have the same stamp and
|
|
64
|
+
// "the newest" is whatever order readdir happened to return. Without this
|
|
65
|
+
// the retained set differs per filesystem.
|
|
66
|
+
.sort((a, b) => b.mtime - a.mtime || (a.full < b.full ? 1 : a.full > b.full ? -1 : 0));
|
|
67
|
+
|
|
68
|
+
// keepFiles is the size of the directory after the prune, not the size of
|
|
69
|
+
// the part of it retention got to decide. A promised file is one of the
|
|
70
|
+
// survivors, so it takes one of the slots - reserved up front rather than
|
|
71
|
+
// counted as the loop reaches it, because otherwise the answer depends on
|
|
72
|
+
// where that file happens to sort. It used to be neither: a keepSet entry
|
|
73
|
+
// was skipped without consuming anything, so keepFiles: 1 plus one promised
|
|
74
|
+
// file left TWO files behind. On APFS the promised file is genuinely the
|
|
75
|
+
// newest and sorted first, which hid it; on a filesystem whose mtimes tie it
|
|
76
|
+
// sorted anywhere and the directory kept a stale file forever.
|
|
77
|
+
const reserved = entries.filter((e) => keepSet.has(resolve(e.full))).length;
|
|
78
|
+
const budget = Math.max(0, keepFiles - reserved);
|
|
79
|
+
let kept = 0;
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (keepSet.has(resolve(entry.full))) continue;
|
|
82
|
+
const tooOld = entry.mtime < cutoff;
|
|
83
|
+
const tooMany = kept >= budget;
|
|
84
|
+
if (!tooOld && !tooMany) {
|
|
85
|
+
kept++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
68
88
|
try {
|
|
69
|
-
rmSync(
|
|
89
|
+
rmSync(entry.full, { recursive: true, force: true });
|
|
70
90
|
removed++;
|
|
71
91
|
} catch {
|
|
72
92
|
// A file another process holds open is skipped, not fatal.
|