@mmerterden/multi-agent-toolkit-mcp 3.13.0 → 3.14.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 +106 -21
- package/README.md +3 -2
- package/README.tr.md +2 -1
- package/index.js +251 -26
- package/package.json +17 -4
- package/tools/context/index.js +30 -13
- package/tools/offload/index.js +57 -1
- package/tools/security/cvss.js +108 -0
- package/tools/security/deps.js +0 -0
- package/tools/security/index.js +115 -0
package/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,76 @@ Releases before this file exists are recorded in the git tags and commit history
|
|
|
19
19
|
|
|
20
20
|
---
|
|
21
21
|
|
|
22
|
+
## 3.13.1 - 2026-09-21
|
|
23
|
+
|
|
24
|
+
Fixes to the web and context families, each held by a gate proven to fail
|
|
25
|
+
without it. The acceptance numbers at the end were measured against live sites.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **A question asked of the index returned nothing at all.** FTS5 reads the
|
|
30
|
+
space between two terms as AND, so a nine-word question demanded all nine
|
|
31
|
+
words from one chunk. `context_search` now runs the every-term query first,
|
|
32
|
+
because a caller who typed all the terms meant all of them, and falls back to
|
|
33
|
+
an any-term query when that finds nothing; `bm25` then ranks by how many of
|
|
34
|
+
the terms a chunk carries and how rare each one is. A log line still matches
|
|
35
|
+
exactly as before.
|
|
36
|
+
- **`web_crawl`, `web_extract` and `web_map` crawled `about:blank`.** Run before
|
|
37
|
+
the session has navigated, extraction succeeded and returned nothing: the
|
|
38
|
+
crawl reported one page, the index gained an empty document, and the next
|
|
39
|
+
search ranked it. The three now name the missing `web_goto` instead of
|
|
40
|
+
returning an empty success.
|
|
41
|
+
- **A bounded crawl spent its budget on site furniture.** Links were followed in
|
|
42
|
+
DOM order, so a global index, a feedback form and a breadcrumb came before the
|
|
43
|
+
second body link. Links inside the page's content region are handed back
|
|
44
|
+
first, and a link that differs from another only by its query string is
|
|
45
|
+
treated as the same document. Measured on a 20-page crawl of the Python
|
|
46
|
+
standard library index: 14 material pages before, 18 after.
|
|
47
|
+
- **Refs from inside a frame were never registered.** Playwright stamps a frame
|
|
48
|
+
generation onto refs handed out past the first navigation (`f2e8`, not `e8`),
|
|
49
|
+
so the registry accepted nothing from the second page onward and refused every
|
|
50
|
+
ref the snapshot plainly showed.
|
|
51
|
+
- **`web_click` reported a timeout without saying what caused it.** A refusal
|
|
52
|
+
now names the reason: disabled, not displayed, transparent, zero-sized,
|
|
53
|
+
off-viewport, `pointer-events: none`, or the element that is on top of it at
|
|
54
|
+
the click point.
|
|
55
|
+
- **Readable extraction fell back to the page source.** The Readability and
|
|
56
|
+
Turndown browser bundles are resolved by their browser filenames and injected
|
|
57
|
+
into the page; when neither is available the fallback reads the live content
|
|
58
|
+
container's rendered text and drops chrome line by line.
|
|
59
|
+
- **`context_search` said which lines matched but not which file.** Each hit now
|
|
60
|
+
carries its path.
|
|
61
|
+
|
|
62
|
+
### Added
|
|
63
|
+
|
|
64
|
+
- **`web_crawl` feeds the full-text index.** Each page is written beside the
|
|
65
|
+
index and indexed under its URL, so twenty pages arrive searchable instead of
|
|
66
|
+
as a wall of text. Failure to index never fails the crawl.
|
|
67
|
+
- **An offloaded payload's summary carries its signal lines.** A head-and-tail
|
|
68
|
+
window keeps the two places a large payload is least likely to carry its
|
|
69
|
+
answer; the tool name is already known, so up to twenty matching lines are
|
|
70
|
+
surfaced with their line numbers - `FATAL` and `Abort trap` for a crash log,
|
|
71
|
+
`error:` and `Undefined symbol` for a build, tappable nodes for a UI dump,
|
|
72
|
+
measurements for a memory report. Patterns only, no model.
|
|
73
|
+
- **`@mozilla/readability` and `turndown` as optional peers**, alongside
|
|
74
|
+
`playwright`. Absent, extraction uses the fallback above.
|
|
75
|
+
- **`MCP_TOOLKIT_INDEX_DIR`** overrides where the index lives, so a gate ranks a
|
|
76
|
+
corpus it owns.
|
|
77
|
+
|
|
78
|
+
### Acceptance
|
|
79
|
+
|
|
80
|
+
Run against live sites before release:
|
|
81
|
+
|
|
82
|
+
| Criterion | Result |
|
|
83
|
+
| ---------------------------------------------------------------------------------------- | --------------------- |
|
|
84
|
+
| Click by ref on ten real sites, asserting the page moved | 10/10 |
|
|
85
|
+
| Article body extracted from ten pages, no nav, cookie or footer text | 10/10 |
|
|
86
|
+
| `max_pages: 20` crawl of a documentation site, then five questions ranked from the index | 5/5 first-hit correct |
|
|
87
|
+
|
|
88
|
+
None of the five questions names the module it should find.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
22
92
|
## 3.13.0 - 2026-09-20
|
|
23
93
|
|
|
24
94
|
### Added
|
|
@@ -177,8 +247,7 @@ because the thing that broke was the assumption that the host is a Mac.
|
|
|
177
247
|
`pass_inspect`, `pass_certificates`. Build, sign, validate and inspect Apple
|
|
178
248
|
Wallet passes; offline, deterministic, and generic by construction. All five
|
|
179
249
|
of Apple's styles are first-class with a per-style image contract, and no
|
|
180
|
-
brand, airline or project appears anywhere in the directory. 95 tools become
|
|
181
|
-
99. Three are read-only; `pass_build` writes the file the caller named.
|
|
250
|
+
brand, airline or project appears anywhere in the directory. 95 tools become 99. Three are read-only; `pass_build` writes the file the caller named.
|
|
182
251
|
|
|
183
252
|
**A literal passphrase is not an accepted input.** The caller names an
|
|
184
253
|
environment variable (`passphrase_env`) or a keychain entry, and the value
|
|
@@ -576,7 +645,7 @@ unverifiable without a device or emulator to run adb against.
|
|
|
576
645
|
|
|
577
646
|
- **The accessibility audits reported a clean screen they had never read.** On a
|
|
578
647
|
tree that came back empty they returned `elements_scanned: 0, total_issues: 0,
|
|
579
|
-
|
|
648
|
+
critical: 0, important: 0, warning: 0` - a full clean bill of health,
|
|
580
649
|
indistinguishable from an accessible screen, and the first reading is the one
|
|
581
650
|
anyone believes. Both audits now carry `measurable` and a `reason`, and an
|
|
582
651
|
unmeasurable run reports `total_issues: null`, never 0. A count of zero is a
|
|
@@ -704,7 +773,7 @@ declare `>= 3.1.0`.
|
|
|
704
773
|
`~/.claude/logs/multi-agent-toolkit/<tool>-<timestamp>.txt`, and the tool
|
|
705
774
|
returns a head + tail window with the line count and the path.
|
|
706
775
|
`agent_query_output {pattern, path?, context_lines?, max_matches?,
|
|
707
|
-
|
|
776
|
+
ignore_case?}` searches that file and returns matching lines with numbered
|
|
708
777
|
context, so a follow-up question does not mean re-running an expensive tool -
|
|
709
778
|
and for a UI dump a second run is not even the same evidence.
|
|
710
779
|
|
|
@@ -787,7 +856,7 @@ removed (still 83); input handling is tightened but stays backward-compatible.
|
|
|
787
856
|
- `ios_record_video` and `android_record_screen` actually record. Both previously
|
|
788
857
|
returned a literal "Run: ..." instruction for the caller to execute by hand. Now
|
|
789
858
|
`action:"start"` spawns the recorder (`simctl io recordVideo` / `adb shell
|
|
790
|
-
|
|
859
|
+
screenrecord`) in the background with PID tracking and returns immediately;
|
|
791
860
|
`action:"stop"` interrupts it (SIGINT, so the mp4 container is finalized), pulls
|
|
792
861
|
the file off the device on Android, and returns the local path. One recording
|
|
793
862
|
per device; a stop with nothing running is an error.
|
|
@@ -1006,10 +1075,11 @@ Documentation that claimed more than it checked, and the gate that let it.
|
|
|
1006
1075
|
|
|
1007
1076
|
**Editor support is now stated as verified vs compatible.** The README opened with
|
|
1008
1077
|
"Works with Claude Code, Copilot CLI, Cursor, Antigravity, and VS Code Copilot Chat
|
|
1078
|
+
|
|
1009
1079
|
- the multi-agent-pipeline's full-orchestration targets". That parenthetical stopped
|
|
1010
|
-
being true in pipeline v10.7.0, when the Cursor / Antigravity / Codex / Copilot Chat
|
|
1011
|
-
adapters were deleted; the pipeline has targeted Claude Code and Copilot CLI only
|
|
1012
|
-
ever since.
|
|
1080
|
+
being true in pipeline v10.7.0, when the Cursor / Antigravity / Codex / Copilot Chat
|
|
1081
|
+
adapters were deleted; the pipeline has targeted Claude Code and Copilot CLI only
|
|
1082
|
+
ever since.
|
|
1013
1083
|
|
|
1014
1084
|
The list is not simply wrong, though: this is a plain stdio MCP server, so it does
|
|
1015
1085
|
run in any MCP client, and the config snippets for those clients stay. What was
|
|
@@ -1021,12 +1091,12 @@ of the 78 - in the primary host, for two releases, with every gate green.
|
|
|
1021
1091
|
**Gate 3b: per-family counts, not just the headline.** Gate 3 checked the two
|
|
1022
1092
|
headline numbers and nothing else, so every subcount drifted behind a green check:
|
|
1023
1093
|
|
|
1024
|
-
| README said
|
|
1025
|
-
|
|
1026
|
-
| Device Control (42 tools)
|
|
1027
|
-
| Store Compliance (4 tools)
|
|
1028
|
-
| iOS Tools (34), diagram (31) | 35
|
|
1029
|
-
| 5 categories summing to 57
|
|
1094
|
+
| README said | reality |
|
|
1095
|
+
| ---------------------------- | ------- |
|
|
1096
|
+
| Device Control (42 tools) | 58 |
|
|
1097
|
+
| Store Compliance (4 tools) | 3 |
|
|
1098
|
+
| iOS Tools (34), diagram (31) | 35 |
|
|
1099
|
+
| 5 categories summing to 57 | 78 |
|
|
1030
1100
|
|
|
1031
1101
|
The last row is the interesting one: the whole `design_*` family, six tools, was
|
|
1032
1102
|
missing from the category list entirely, which is why the listed categories could
|
|
@@ -1250,15 +1320,30 @@ paths and requires an error on each. Gate 9 requires every tool advertising an
|
|
|
1250
1320
|
outputSchema to actually return `structuredContent`, and to keep its text
|
|
1251
1321
|
content beside it. 14 gates, all green.
|
|
1252
1322
|
|
|
1323
|
+
## [3.14.0] - 2026-09-21
|
|
1324
|
+
|
|
1325
|
+
### Added
|
|
1326
|
+
|
|
1327
|
+
- **`security` family: `security_cvss_score` and `security_dep_inventory`.**
|
|
1328
|
+
security_cvss_score turns a CVSS 3.1 base vector into a score and band by the
|
|
1329
|
+
FIRST arithmetic, so a finding's number cannot drift from its vector.
|
|
1330
|
+
security_dep_inventory normalizes a lockfile (npm, yarn, pnpm, CocoaPods, pip,
|
|
1331
|
+
Go, Cargo, RubyGems) into {ecosystem, name, version}. Both are pure JS and
|
|
1332
|
+
contact nothing - matching an inventory against known CVEs stays a separate,
|
|
1333
|
+
network-bearing step, so the stdio-only egress surface is unchanged. Gated at
|
|
1334
|
+
serve time by `MCP_TOOLKIT_CAPS=security`. 117 tools total.
|
|
1335
|
+
|
|
1253
1336
|
## [3.0.0] - 2026-08-22
|
|
1254
1337
|
|
|
1255
1338
|
### Changed
|
|
1339
|
+
|
|
1256
1340
|
- **Renamed to `@mmerterden/multi-agent-toolkit-mcp`.** The old name read as internal scaffolding for one pipeline; this server is a standalone MCP over stdio with three runtime dependencies and no coupling to any orchestrator, and the name now says which family it belongs to. Major, because a package rename breaks every consumer that resolves it by name.
|
|
1257
1341
|
- **The MCP server identity reported over the protocol is now `multi-agent-toolkit-mcp`.** Hosts key their registration off this, so an existing `dev-toolkit` entry does not upgrade in place - it has to be removed and re-added. The pipeline installer does that automatically; a hand-registered client needs `<cli> mcp remove dev-toolkit` once.
|
|
1258
1342
|
- **`dev-toolkit-mcp` is kept as a second `bin` alias** so a script that invokes the old binary name keeps working through the transition.
|
|
1259
1343
|
- Package description and keywords lead with what the server does (iOS Simulator, Android Emulator, headless web, 83 tools) rather than with the family it ships in, because that is what someone searching for it will search for.
|
|
1260
1344
|
|
|
1261
1345
|
### Migration
|
|
1346
|
+
|
|
1262
1347
|
- `npm i -g @mmerterden/multi-agent-toolkit-mcp` then remove the old registration: `claude mcp remove dev-toolkit` (same for `copilot` / `codex`).
|
|
1263
1348
|
- `@mmerterden/dev-toolkit-mcp` stays published at 2.26.0 and is deprecated with a pointer to the new name. Nothing is unpublished; a pinned consumer keeps resolving.
|
|
1264
1349
|
|
|
@@ -1480,7 +1565,7 @@ rarely the same size.
|
|
|
1480
1565
|
size finding that buried the real defects. The comparison now reports
|
|
1481
1566
|
per-element edge insets (left/right) rather than raw width, gaps between
|
|
1482
1567
|
consecutive elements, vertical placement, and font size, family and text
|
|
1483
|
-
colour sampled from the text ink rather than the box average -
|
|
1568
|
+
colour sampled from the text ink rather than the box average - the box average
|
|
1484
1569
|
previously returned the background colour and so passed every check. Pass
|
|
1485
1570
|
`responsive: false` to restore the previous absolute-delta behaviour.
|
|
1486
1571
|
- **Height is advisory.** It is still reported, but a content-driven height
|
|
@@ -1512,20 +1597,20 @@ because no failure ever carried `isError`, no host could tell.
|
|
|
1512
1597
|
### Fixed
|
|
1513
1598
|
|
|
1514
1599
|
- **`isError` on every failure.** The `CallTool` dispatch returned command
|
|
1515
|
-
failures as ordinary text, so a host -
|
|
1516
|
-
results -
|
|
1600
|
+
failures as ordinary text, so a host - and the pipeline gates reading these
|
|
1601
|
+
results - saw failure as success. Failures now return `isError: true`. Failing
|
|
1517
1602
|
command output is also capped at 600 chars: a failed `simctl` call was
|
|
1518
1603
|
inlining its entire ~3 KB usage page into the caller's context.
|
|
1519
1604
|
- **`ios_biometric` was a no-op that claimed success.** `simctl keychain
|
|
1520
|
-
|
|
1605
|
+
<device> biometric-enroll` / `biometric-match` do not exist - `keychain`
|
|
1521
1606
|
supports only `add-root-cert`, `add-cert`, `reset`. Now drives the BiometricKit
|
|
1522
1607
|
notification via `notifyutil` inside the simulator, and because `notifyutil`
|
|
1523
1608
|
exits 0 even when it cannot post the name, its output is inspected: a "Failed
|
|
1524
1609
|
with code N" line is reported as a failure with next steps instead of
|
|
1525
1610
|
"success simulated".
|
|
1526
1611
|
- **`ios_go_home` was a no-op.** `simctl io <device> pressButton` does not exist
|
|
1527
|
-
-
|
|
1528
|
-
|
|
1612
|
+
- `io` supports only `enumerate`, `poll`, `recordVideo`, `screenshot`,
|
|
1613
|
+
`screenConfig`. Routed through `idb ui button HOME`, like tap/swipe/type.
|
|
1529
1614
|
- **`android_set_locale` reported success unconditionally.** It broadcast the
|
|
1530
1615
|
dead pre-Android-7 `SET_LOCALE` intent with stderr sent to `/dev/null`, and
|
|
1531
1616
|
`am broadcast` exits 0 even when nothing handles the intent. Now uses the
|
|
@@ -1550,7 +1635,7 @@ because no failure ever carried `isError`, no host could tell.
|
|
|
1550
1635
|
|
|
1551
1636
|
- **`sdk-floor` audit rule** (ITMS-90725), bringing `ios_app_store_audit` to
|
|
1552
1637
|
**18 rules**. Asserts `DTSDKName` / `DTPlatformVersion` major >= 26 and
|
|
1553
|
-
`DTXcode` >= 2600, the iOS 26 / Xcode 26 floor in force since 2026-04-28 -
|
|
1638
|
+
`DTXcode` >= 2600, the iOS 26 / Xcode 26 floor in force since 2026-04-28 - a
|
|
1554
1639
|
hard upload rejection that nothing checked. Grouped as `core` because it costs
|
|
1555
1640
|
nothing to run. Reports a WARNING when an archive carries no build receipts at
|
|
1556
1641
|
all, rather than passing silently.
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
🇹🇷 Türkçe: [README.tr.md](./README.tr.md)
|
|
9
9
|
|
|
10
|
-
**
|
|
10
|
+
**117 tools** for iOS Simulator, Android Emulator, and headless web control. MCP server that lets your AI coding assistant see, interact with, and audit your mobile apps - plus drive browsers, run an 18-rule App Store compliance audit, and orchestrate multi-step batch flows.
|
|
11
11
|
|
|
12
12
|
Distributed on the **public npm registry** - `npx @mmerterden/multi-agent-toolkit-mcp` resolves with no auth, no token, no `~/.npmrc` setup.
|
|
13
13
|
|
|
@@ -15,7 +15,7 @@ Distributed on the **public npm registry** - `npx @mmerterden/multi-agent-toolki
|
|
|
15
15
|
|
|
16
16
|
That distinction is worth keeping straight. This line once called five hosts "the multi-agent-pipeline's full-orchestration targets", which stopped being true in pipeline v10.7.0 when the Cursor / Antigravity / Codex / Copilot Chat adapters were deleted. Codex CLI returned as a first-class target in pipeline v13.0.0 on capability - it now has skills, parallel sub-agents, hooks and MCP - and it is on the verified list only because a gate exercises it, not because it is supported elsewhere. Claiming hosts nobody exercises is not free either: 2.20.0 and 2.21.0 shipped an illegal `outputSchema` on the first tool, so Claude Code rejected the whole `tools/list` and served zero of the 78 - in the primary host, for two releases, with every gate in this repo green.
|
|
17
17
|
|
|
18
|
-
**
|
|
18
|
+
**14 categories:**
|
|
19
19
|
- **Device Control** (59 tools) - screenshot, tap, swipe, type, navigate, dark mode, locale, orientation, location, permissions, push notifications, real start/stop screen recording
|
|
20
20
|
- **Memory** (2 tools) - `ios_leaks` runs /usr/bin/leaks against a simulator or host process, snapshot or diff against a saved memory graph; `android_meminfo` reads dumpsys meminfo, snapshot or diff. Both report `measurable:false` rather than a clean result when they could not look
|
|
21
21
|
- **Crash Diagnostics** (2 tools) - `ios_list_crashes` reads the host's DiagnosticReports, `android_list_crashes` dumps the adb crash buffer, both tail-bounded
|
|
@@ -29,6 +29,7 @@ That distinction is worth keeping straight. This line once called five hosts "th
|
|
|
29
29
|
- **Research** (2 tools) - provider-backed web search normalized to {title, url, snippet}, and a cited answer through Perplexity Sonar. Keys are read from the environment at call time and go straight into a header; a literal key is not an accepted argument, the same contract the Wallet signer uses
|
|
30
30
|
- **Media** (1 tool) - `media_frames` pulls key frames out of a recording with ffmpeg, dropping near-duplicates, so the video `ios_record_video` just produced becomes something a model can actually look at
|
|
31
31
|
- **Autonomous Agent DSL** (2 tools) - `agent_run_steps` executes a batch array of {tool, args, continue_on_error?, wait_ms?} steps in one MCP round trip. Ideal for scripted login flows, form fills, multi-step QA paths. `agent_query_output` searches the full output of an earlier call that was too large to return inline, so a follow-up question does not mean re-running an expensive tool.
|
|
32
|
+
- **Security** (2 tools) - offline, no egress: `security_cvss_score` turns a CVSS 3.1 base vector into a score and band by the FIRST arithmetic, so a finding's number cannot drift from its vector; `security_dep_inventory` normalizes a lockfile (npm, yarn, pnpm, CocoaPods, pip, Go, Cargo, RubyGems) into `{ecosystem, name, version}` for a separate CVE lookup to consume. Neither contacts a registry
|
|
32
33
|
|
|
33
34
|
## Quick Start
|
|
34
35
|
|
package/README.tr.md
CHANGED
|
@@ -15,7 +15,7 @@ iOS Simulator, Android Emulator ve headless web kontrolü için **115 araç**. A
|
|
|
15
15
|
|
|
16
16
|
Bu ayrımı net tutmakta fayda var. Bu satır bir zamanlar beş host'u "multi-agent-pipeline'ın tam-orkestrasyon hedefleri" olarak adlandırıyordu, bu da pipeline v10.7.0'da Cursor / Antigravity / Codex / Copilot Chat adaptörleri silindiğinde doğru olmaktan çıktı. Codex CLI, pipeline v13.0.0'da yetenek üzerinden birinci-sınıf bir hedef olarak geri döndü - artık skill'leri, paralel sub-agent'ları, hook'ları ve MCP'si var - ve doğrulanmış listede sadece bir kapı onu çalıştırdığı için yer alıyor, başka bir yerde desteklendiği için değil. Kimsenin çalıştırmadığı host'ları iddia etmek de bedavaya gelmiyor: 2.20.0 ve 2.21.0, ilk araçta yasa dışı bir `outputSchema` gönderdi, bu yüzden Claude Code tüm `tools/list`'i reddetti ve 78'in sıfırını sundu - birincil host'ta, iki release boyunca, bu repo'daki her kapı yeşilken.
|
|
17
17
|
|
|
18
|
-
**
|
|
18
|
+
**14 kategori:**
|
|
19
19
|
- **Device Control** (59 araç) - screenshot, tap, swipe, type, navigate, dark mode, locale, orientation, location, permissions, push notifications, gerçek start/stop ekran kaydı
|
|
20
20
|
- **Memory** (2 araç) - `ios_leaks`, bir simülatör ya da host süreci üzerinde /usr/bin/leaks çalıştırır; anlık görüntü alır ya da kayıtlı bir memory graph ile karşılaştırır. `android_meminfo`, dumpsys meminfo okur, aynı şekilde anlık görüntü ya da fark. İkisi de bakamadıklarında temiz bir sonuç değil `measurable:false` döner
|
|
21
21
|
- **Crash Diagnostics** (2 araç) - `ios_list_crashes` host'un DiagnosticReports dizinini okur, `android_list_crashes` adb crash buffer'ını döker, ikisi de tail-sınırlı
|
|
@@ -29,6 +29,7 @@ Bu ayrımı net tutmakta fayda var. Bu satır bir zamanlar beş host'u "multi-ag
|
|
|
29
29
|
- **Research** (2 araç) - sağlayıcı destekli web araması, {title, url, snippet} şemasına normalize edilmiş; ve Perplexity Sonar üzerinden atıflı cevap. Anahtarlar çağrı anında ortamdan okunup doğrudan bir başlığa gider; düz metin anahtar kabul edilen bir argüman değil - Wallet imzalayıcısının kullandığı sözleşmenin aynısı
|
|
30
30
|
- **Media** (1 araç) - `media_frames`, bir kaydın anahtar karelerini ffmpeg ile çıkarır ve birbirine benzeyenleri eler; böylece `ios_record_video`'nun ürettiği video bir modelin gerçekten bakabileceği bir şeye dönüşür
|
|
31
31
|
- **Autonomous Agent DSL** (2 araç) - `agent_run_steps`, tek bir MCP round trip'inde {tool, args, continue_on_error?, wait_ms?} adımlarından oluşan bir batch dizisini çalıştırır. Scriptlenmiş login akışları, form doldurma, çok-adımlı QA yolları için ideal. `agent_query_output`, satır içi dönemeyecek kadar büyük olan önceki bir çağrının tam çıktısını arar; böylece bir takip sorusu pahalı aracı yeniden koşturmak anlamına gelmez.
|
|
32
|
+
- **Security** (2 araç) - çevrimdışı, egress yok: `security_cvss_score` bir CVSS 3.1 temel vektörünü FIRST aritmetiğiyle skora ve banda çevirir, böylece bir bulgunun sayısı vektöründen sapamaz; `security_dep_inventory` bir lockfile'ı (npm, yarn, pnpm, CocoaPods, pip, Go, Cargo, RubyGems) `{ecosystem, name, version}` listesine normalize eder, ayrı bir CVE aramasının tüketmesi için. İkisi de bir registry'ye bağlanmaz
|
|
32
33
|
|
|
33
34
|
## Hızlı Başlangıç
|
|
34
35
|
|
package/index.js
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
shutdownAllLsp,
|
|
47
47
|
} from "./tools/code-intel/index.js";
|
|
48
48
|
import { PASS_TOOLS, handlePass, PASS_READ_ONLY, PASS_OUTPUT_SCHEMAS } from "./tools/pass-kit/index.js";
|
|
49
|
+
import { SECURITY_TOOLS, handleSecurity, SECURITY_OUTPUT_SCHEMAS } from "./tools/security/index.js";
|
|
49
50
|
import { parseLaunchOutput } from "./tools/launch-time/index.js";
|
|
50
51
|
import { parseLeaksOutput, parseMeminfoOutput, diffMeminfo } from "./tools/memory/index.js";
|
|
51
52
|
import { auditIosTree, auditAndroidDump, parseAuditResults } from "./tools/a11y/index.js";
|
|
@@ -1589,15 +1590,82 @@ function fenceUntrusted(source, body) {
|
|
|
1589
1590
|
return `${UNTRUSTED_OPEN} (${source}) <<<\n${body}\n${UNTRUSTED_CLOSE}`;
|
|
1590
1591
|
}
|
|
1591
1592
|
|
|
1593
|
+
// Why an element that exists refused to be acted on. Returns null when nothing
|
|
1594
|
+
// obvious is wrong, so the caller still gets Playwright's own message rather
|
|
1595
|
+
// than a guess dressed up as a diagnosis.
|
|
1596
|
+
async function describeUnactionable(locator) {
|
|
1597
|
+
try {
|
|
1598
|
+
if ((await locator.count()) === 0) return "no element matches it any more";
|
|
1599
|
+
const state = await locator.evaluate((el) => {
|
|
1600
|
+
const cs = getComputedStyle(el);
|
|
1601
|
+
const r = el.getBoundingClientRect();
|
|
1602
|
+
return {
|
|
1603
|
+
display: cs.display,
|
|
1604
|
+
visibility: cs.visibility,
|
|
1605
|
+
opacity: cs.opacity,
|
|
1606
|
+
pointerEvents: cs.pointerEvents,
|
|
1607
|
+
w: r.width,
|
|
1608
|
+
h: r.height,
|
|
1609
|
+
offLeft: r.right <= 0,
|
|
1610
|
+
offTop: r.bottom <= 0,
|
|
1611
|
+
disabled: el.hasAttribute("disabled") || el.getAttribute("aria-disabled") === "true",
|
|
1612
|
+
label: (el.innerText || el.getAttribute("aria-label") || "").trim().slice(0, 40),
|
|
1613
|
+
};
|
|
1614
|
+
});
|
|
1615
|
+
const named = state.label ? ` ("${state.label}")` : "";
|
|
1616
|
+
if (state.disabled) return `it is disabled${named}`;
|
|
1617
|
+
if (state.display === "none") return `it is display:none${named}`;
|
|
1618
|
+
if (state.visibility === "hidden") return `it is visibility:hidden${named}`;
|
|
1619
|
+
if (Number(state.opacity) === 0) return `it is fully transparent${named}`;
|
|
1620
|
+
if (state.w === 0 || state.h === 0) return `it has no size${named}`;
|
|
1621
|
+
if (state.offLeft || state.offTop) {
|
|
1622
|
+
return `it sits outside the viewport${named} - a skip link or an off-screen menu, visible only once focused`;
|
|
1623
|
+
}
|
|
1624
|
+
if (state.pointerEvents === "none") return `it ignores pointer events${named}`;
|
|
1625
|
+
|
|
1626
|
+
// Everything above says the element is there and paintable, which is where
|
|
1627
|
+
// Playwright's timeout usually comes from: it waits to deliver a pointer
|
|
1628
|
+
// event and something else is on top. Asking the document what is actually
|
|
1629
|
+
// at that point names the blocker instead of leaving the caller with a
|
|
1630
|
+
// duration.
|
|
1631
|
+
const covering = await locator.evaluate((el) => {
|
|
1632
|
+
const r = el.getBoundingClientRect();
|
|
1633
|
+
const x = r.left + r.width / 2;
|
|
1634
|
+
const y = r.top + r.height / 2;
|
|
1635
|
+
if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) return "__outside__";
|
|
1636
|
+
const hit = document.elementFromPoint(x, y);
|
|
1637
|
+
if (!hit || hit === el || el.contains(hit) || hit.contains(el)) return null;
|
|
1638
|
+
const name = hit.tagName.toLowerCase();
|
|
1639
|
+
const id = hit.id ? `#${hit.id}` : "";
|
|
1640
|
+
const cls = hit.className && typeof hit.className === "string"
|
|
1641
|
+
? `.${hit.className.trim().split(/\s+/).slice(0, 2).join(".")}`
|
|
1642
|
+
: "";
|
|
1643
|
+
return `${name}${id}${cls}`;
|
|
1644
|
+
});
|
|
1645
|
+
if (covering === "__outside__") {
|
|
1646
|
+
return `its centre is outside the viewport${named} - scroll to it, or pick a target that is on screen`;
|
|
1647
|
+
}
|
|
1648
|
+
if (covering) {
|
|
1649
|
+
return `${covering} is on top of it${named} - a sticky header or an overlay is taking the click`;
|
|
1650
|
+
}
|
|
1651
|
+
return null;
|
|
1652
|
+
} catch {
|
|
1653
|
+
return null;
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1592
1657
|
async function extractReadable(page) {
|
|
1593
1658
|
const paths = [];
|
|
1594
1659
|
for (const mod of ["@mozilla/readability", "turndown"]) {
|
|
1595
1660
|
try { paths.push(requireFrom.resolve(mod)); } catch { paths.push(null); }
|
|
1596
1661
|
}
|
|
1597
|
-
|
|
1662
|
+
const bundles = paths[0] && paths[1]
|
|
1663
|
+
? [readabilityBundlePath(paths[0]), turndownBundlePath(paths[1])]
|
|
1664
|
+
: [null, null];
|
|
1665
|
+
if (bundles[0] && bundles[1]) {
|
|
1598
1666
|
try {
|
|
1599
|
-
await page.addScriptTag({ path:
|
|
1600
|
-
await page.addScriptTag({ path:
|
|
1667
|
+
await page.addScriptTag({ path: bundles[0] });
|
|
1668
|
+
await page.addScriptTag({ path: bundles[1] });
|
|
1601
1669
|
const out = await page.evaluate(() => {
|
|
1602
1670
|
const clone = document.cloneNode(true);
|
|
1603
1671
|
// eslint-disable-next-line no-undef
|
|
@@ -1612,35 +1680,127 @@ async function extractReadable(page) {
|
|
|
1612
1680
|
// fall through to the plain extraction
|
|
1613
1681
|
}
|
|
1614
1682
|
}
|
|
1683
|
+
// The fallback used to clone the body, strip chrome from the clone and read
|
|
1684
|
+
// its innerText. innerText is computed from layout, and a detached node has
|
|
1685
|
+
// none - so on a real page it returned a fraction of the text or nothing,
|
|
1686
|
+
// while a small fixture with everything above the fold looked fine.
|
|
1687
|
+
//
|
|
1688
|
+
// So: find the LIVE container that holds the article, read its innerText, and
|
|
1689
|
+
// drop chrome by line. Reading the live node is what makes the text real; the
|
|
1690
|
+
// line filter is what keeps "Jump to content" out of it.
|
|
1615
1691
|
return page.evaluate(() => {
|
|
1616
|
-
const
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1692
|
+
const CONTAINERS = [
|
|
1693
|
+
"main article",
|
|
1694
|
+
"article",
|
|
1695
|
+
"main",
|
|
1696
|
+
"[role=main]",
|
|
1697
|
+
"#mw-content-text",
|
|
1698
|
+
"#content",
|
|
1699
|
+
".markdown-body",
|
|
1700
|
+
".post-content",
|
|
1701
|
+
];
|
|
1702
|
+
let host = null;
|
|
1703
|
+
for (const sel of CONTAINERS) {
|
|
1704
|
+
const el = document.querySelector(sel);
|
|
1705
|
+
if (el && (el.innerText || "").trim().length > 200) {
|
|
1706
|
+
host = el;
|
|
1707
|
+
break;
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
if (!host) host = document.body;
|
|
1711
|
+
|
|
1712
|
+
const CHROME = [
|
|
1713
|
+
/^jump to (content|navigation|search)$/i,
|
|
1714
|
+
/^skip to (main|content)/i,
|
|
1715
|
+
/^(main )?menu$/i,
|
|
1716
|
+
/^toggle the table of contents$/i,
|
|
1717
|
+
/^(search|sign in|log in|sign up|subscribe|donate)$/i,
|
|
1718
|
+
/^(accept|reject) (all )?cookies?/i,
|
|
1719
|
+
/^cookie (settings|preferences)/i,
|
|
1720
|
+
/^edit (source|this page)$/i,
|
|
1721
|
+
/^\[edit\]$/i,
|
|
1722
|
+
/^(privacy policy|terms of use|contact us)$/i,
|
|
1723
|
+
];
|
|
1724
|
+
const kept = (host.innerText || "")
|
|
1725
|
+
.split("\n")
|
|
1726
|
+
.filter((line) => {
|
|
1727
|
+
const t = line.trim();
|
|
1728
|
+
if (!t) return true;
|
|
1729
|
+
return !CHROME.some((rx) => rx.test(t));
|
|
1730
|
+
})
|
|
1731
|
+
.join("\n")
|
|
1732
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
1733
|
+
.trim();
|
|
1734
|
+
|
|
1735
|
+
// document.title carries the site suffix ("Bloom filter - Wikipedia").
|
|
1736
|
+
// The first h1 inside the container is the article's own name.
|
|
1737
|
+
const h1 = host.querySelector("h1") || document.querySelector("h1");
|
|
1738
|
+
const title = (h1 && h1.innerText.trim()) || document.title || "";
|
|
1739
|
+
return { markdown: `# ${title}\n\n${kept}`, text: kept };
|
|
1622
1740
|
});
|
|
1623
1741
|
}
|
|
1624
1742
|
|
|
1743
|
+
// Both packages ship a Node entry point and a browser bundle, and only the
|
|
1744
|
+
// browser bundle can be injected into a page: the Node entry is CommonJS, so
|
|
1745
|
+
// addScriptTag loads it, `module` is undefined, and the whole extraction falls
|
|
1746
|
+
// through to the plain path without ever saying why.
|
|
1747
|
+
//
|
|
1748
|
+
// The file is checked rather than assumed. A layout change upstream returns
|
|
1749
|
+
// null here, which keeps the fallback rather than injecting a file that throws.
|
|
1625
1750
|
function readabilityBundlePath(resolved) {
|
|
1626
|
-
|
|
1751
|
+
const candidate = join(dirname(resolved), "Readability.js");
|
|
1752
|
+
return existsSync(candidate) ? candidate : null;
|
|
1627
1753
|
}
|
|
1628
1754
|
function turndownBundlePath(resolved) {
|
|
1629
|
-
|
|
1755
|
+
for (const name of ["turndown.browser.umd.js", "turndown.umd.js", "turndown.js"]) {
|
|
1756
|
+
const candidate = join(dirname(resolved), name);
|
|
1757
|
+
if (existsSync(candidate)) return candidate;
|
|
1758
|
+
}
|
|
1759
|
+
return null;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// Every tool below reads the page that is already open. A session that has not
|
|
1763
|
+
// navigated yet is sitting on `about:blank`, where extraction succeeds and
|
|
1764
|
+
// returns nothing: the crawl reports "1 page", the index gains an empty
|
|
1765
|
+
// document, and the search that follows ranks it. Name the missing step
|
|
1766
|
+
// instead of returning an empty success.
|
|
1767
|
+
function blankPageError(page, tool) {
|
|
1768
|
+
const url = page.url();
|
|
1769
|
+
if (url && url !== "about:blank" && !url.startsWith("chrome-error://")) return null;
|
|
1770
|
+
return `ERROR: ${tool} reads the page that is currently open, and nothing has been opened yet (${url || "no url"}). Call web_goto first.`;
|
|
1630
1771
|
}
|
|
1631
1772
|
|
|
1632
1773
|
async function sameOriginLinks(page) {
|
|
1633
1774
|
return page.evaluate(() => {
|
|
1634
1775
|
const here = location.origin;
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1776
|
+
// A documentation page carries two kinds of link: the ones in its body,
|
|
1777
|
+
// which are the material, and the site chrome - a global index, a
|
|
1778
|
+
// "report a bug" form, the breadcrumb back to the root - which is the
|
|
1779
|
+
// same handful of pages on every page of the site. A bounded crawl that
|
|
1780
|
+
// takes them in DOM order spends a third of its budget on chrome before
|
|
1781
|
+
// it reaches the second body link, so body links are handed back first.
|
|
1782
|
+
const CONTENT = ["main article", "article", "main", "[role=main]", "#content", ".markdown-body", ".body"];
|
|
1783
|
+
let host = null;
|
|
1784
|
+
for (const sel of CONTENT) {
|
|
1785
|
+
const el = document.querySelector(sel);
|
|
1786
|
+
if (el && el.querySelector("a[href]")) { host = el; break; }
|
|
1787
|
+
}
|
|
1788
|
+
const collect = (root) => {
|
|
1789
|
+
const found = [];
|
|
1790
|
+
for (const a of root.querySelectorAll("a[href]")) {
|
|
1791
|
+
let u;
|
|
1792
|
+
try { u = new URL(a.getAttribute("href"), location.href); } catch { continue; }
|
|
1793
|
+
if (u.origin !== here) continue;
|
|
1794
|
+
// A link that differs from another only by its query string is the
|
|
1795
|
+
// same document with a feedback form or a tracking tag attached.
|
|
1796
|
+
if (u.search && /^(https?:)?[^?]*\.(html?|md|txt)$/i.test(u.pathname)) u.search = "";
|
|
1797
|
+
u.hash = "";
|
|
1798
|
+
found.push(u.toString());
|
|
1799
|
+
}
|
|
1800
|
+
return found;
|
|
1801
|
+
};
|
|
1802
|
+
const ordered = host ? [...collect(host), ...collect(document)] : collect(document);
|
|
1803
|
+
return [...new Set(ordered)];
|
|
1644
1804
|
});
|
|
1645
1805
|
}
|
|
1646
1806
|
|
|
@@ -1724,7 +1884,7 @@ const WEB_TOOLS = [
|
|
|
1724
1884
|
{ name: "web_storage_state", description: "Read cookies and localStorage for the current page, or restore a previously read state.", inputSchema: { type: "object", properties: { restore: { type: "string", description: "A state JSON string from a previous call. Omit to read." } } } },
|
|
1725
1885
|
{ name: "web_extract", description: "The page's article content as Markdown, with navigation, footers and cookie banners dropped. Falls back to a plain text extraction when the readability helpers are not installed.", inputSchema: { type: "object", properties: { as: { type: "string", enum: ["markdown", "text"], description: "Default markdown." } } } },
|
|
1726
1886
|
{ name: "web_map", description: "Same-origin links reachable from the current page, deduplicated. A cheap site map without fetching anything.", inputSchema: { type: "object", properties: { limit: { type: "number", description: "Default 200." } } } },
|
|
1727
|
-
{ name: "web_crawl", description: "Follow same-origin links from the current page, extracting each one. Bounded: max_pages default 20 (cap 200), max_depth default 2, one request at a time with a delay, robots.txt respected, and a self-identifying User-Agent.", inputSchema: { type: "object", properties: { max_pages: { type: "number" }, max_depth: { type: "number" }, delay_ms: { type: "number", description: "Default 250." }, respect_robots: { type: "boolean", description: "Default true." } } } },
|
|
1887
|
+
{ name: "web_crawl", description: "Follow same-origin links from the current page, extracting each one and indexing it so context_search can rank the crawl afterwards. Bounded: max_pages default 20 (cap 200), max_depth default 2, one request at a time with a delay, robots.txt respected, and a self-identifying User-Agent.", inputSchema: { type: "object", properties: { max_pages: { type: "number" }, max_depth: { type: "number" }, delay_ms: { type: "number", description: "Default 250." }, index: { type: "boolean", description: "Index each page into the full-text index. Default true - a crawl whose pages cannot be searched afterwards returns a wall of text nobody can query." }, respect_robots: { type: "boolean", description: "Default true." } } } },
|
|
1728
1888
|
{ name: "web_press_key", description: "Press a key, optionally focusing an element first.", inputSchema: { type: "object", properties: { key: { type: "string", description: "Playwright key name: Enter, Escape, Tab, ArrowDown, Control+A ..." }, selector: { type: "string" }, ref: { type: "string" } }, required: ["key"] } },
|
|
1729
1889
|
{ name: "web_select_option", description: "Choose an option in a <select>, by value or by visible label.", inputSchema: { type: "object", properties: { selector: { type: "string" }, ref: { type: "string" }, value: { type: "string" }, label: { type: "string" } } } },
|
|
1730
1890
|
];
|
|
@@ -1768,7 +1928,21 @@ async function handleWeb(name, args) {
|
|
|
1768
1928
|
case "web_click": {
|
|
1769
1929
|
const t = locate(page, args);
|
|
1770
1930
|
if (t.error) return t.error;
|
|
1771
|
-
|
|
1931
|
+
try {
|
|
1932
|
+
await t.locator.first().click({ timeout: args.timeout_ms || 5000 });
|
|
1933
|
+
} catch (e) {
|
|
1934
|
+
// Playwright waits for the element to be actionable and then reports a
|
|
1935
|
+
// timeout, which says nothing about WHY. The commonest reason on a real
|
|
1936
|
+
// page is a target the snapshot listed and the layout hides: the first
|
|
1937
|
+
// link on an accessibility-first site is a "Skip to content" link
|
|
1938
|
+
// positioned off-screen until it takes focus. A caller told only
|
|
1939
|
+
// "Timeout 5000ms exceeded" re-tries the same ref; one told the element
|
|
1940
|
+
// is hidden picks a different one.
|
|
1941
|
+
const why = await describeUnactionable(t.locator.first());
|
|
1942
|
+
return why
|
|
1943
|
+
? `${ERROR_PREFIX}${t.label} could not be clicked: ${why}`
|
|
1944
|
+
: `${ERROR_PREFIX}${t.label} could not be clicked: ${String(e.message || e).split("\n")[0]}`;
|
|
1945
|
+
}
|
|
1772
1946
|
return `Clicked: ${t.label}`;
|
|
1773
1947
|
}
|
|
1774
1948
|
case "web_type": {
|
|
@@ -1799,7 +1973,14 @@ async function handleWeb(name, args) {
|
|
|
1799
1973
|
const tree = await target.ariaSnapshot({ mode: "ai" });
|
|
1800
1974
|
// Register every ref the snapshot handed out, stamped with this
|
|
1801
1975
|
// snapshot's id so a later navigation invalidates them as a set.
|
|
1802
|
-
|
|
1976
|
+
//
|
|
1977
|
+
// An element inside an iframe is handed out as `f<frame>e<n>`, not `e<n>`.
|
|
1978
|
+
// Matching only the bare form registers nothing on a page that frames
|
|
1979
|
+
// anything - a cookie banner, an embedded video, an analytics pixel - so
|
|
1980
|
+
// every ref on it is refused as unknown while the snapshot plainly shows
|
|
1981
|
+
// it. Playwright resolves both spellings through the same `aria-ref=`
|
|
1982
|
+
// engine; only this registry needed to accept them.
|
|
1983
|
+
for (const m of String(tree).matchAll(/\[ref=((?:f\d+)?e\d+)\]/g)) {
|
|
1803
1984
|
_refs.set(m[1], { snapshot: _snapshotId });
|
|
1804
1985
|
}
|
|
1805
1986
|
return `${_refs.size} refs\n${tree}`;
|
|
@@ -1864,24 +2045,43 @@ async function handleWeb(name, args) {
|
|
|
1864
2045
|
}
|
|
1865
2046
|
|
|
1866
2047
|
case "web_extract": {
|
|
2048
|
+
const blank = blankPageError(page, "web_extract");
|
|
2049
|
+
if (blank) return blank;
|
|
1867
2050
|
const text = await extractReadable(page);
|
|
1868
2051
|
const body = args.as === "text" ? text.text : text.markdown;
|
|
1869
2052
|
return fenceUntrusted(page.url(), body);
|
|
1870
2053
|
}
|
|
1871
2054
|
|
|
1872
2055
|
case "web_map": {
|
|
2056
|
+
const blank = blankPageError(page, "web_map");
|
|
2057
|
+
if (blank) return blank;
|
|
1873
2058
|
const links = await sameOriginLinks(page);
|
|
1874
2059
|
const limit = args.limit || 200;
|
|
1875
2060
|
return links.slice(0, limit).join("\n") || "(no same-origin links on this page)";
|
|
1876
2061
|
}
|
|
1877
2062
|
|
|
1878
2063
|
case "web_crawl": {
|
|
2064
|
+
const blank = blankPageError(page, "web_crawl");
|
|
2065
|
+
if (blank) return blank;
|
|
1879
2066
|
const maxPages = Math.min(Number(args.max_pages) || 20, 200);
|
|
1880
2067
|
const maxDepth = Number(args.max_depth) || 2;
|
|
1881
2068
|
const delay = Number(args.delay_ms) || 250;
|
|
1882
2069
|
const respectRobots = args.respect_robots !== false;
|
|
1883
2070
|
const start = page.url();
|
|
1884
2071
|
const origin = new URL(start).origin;
|
|
2072
|
+
const wantIndex = args.index !== false;
|
|
2073
|
+
let indexed = 0;
|
|
2074
|
+
let indexDb = null;
|
|
2075
|
+
let crawlDir = null;
|
|
2076
|
+
if (wantIndex) {
|
|
2077
|
+
try {
|
|
2078
|
+
indexDb = ctxIndex.openIndex();
|
|
2079
|
+
crawlDir = join(ctxIndex.INDEX_DIR, "crawl");
|
|
2080
|
+
if (!existsSync(crawlDir)) mkdirSync(crawlDir, { recursive: true });
|
|
2081
|
+
} catch {
|
|
2082
|
+
indexDb = null;
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
1885
2085
|
const disallowed = respectRobots ? await robotsDisallow(page, origin) : [];
|
|
1886
2086
|
const seen = new Set([start]);
|
|
1887
2087
|
const queue = [{ url: start, depth: 0 }];
|
|
@@ -1897,6 +2097,23 @@ async function handleWeb(name, args) {
|
|
|
1897
2097
|
}
|
|
1898
2098
|
const doc = await extractReadable(page);
|
|
1899
2099
|
out.push(`--- ${url}\n${doc.markdown}`);
|
|
2100
|
+
// A crawl that cannot be searched afterwards is a wall of text: twenty
|
|
2101
|
+
// pages arrive at once and the caller has no way to ask which of them
|
|
2102
|
+
// answers the question. Each page is written beside the index and
|
|
2103
|
+
// indexed under its URL, so context_search ranks the crawl the same way
|
|
2104
|
+
// it ranks an offloaded payload. Failure here never fails the crawl -
|
|
2105
|
+
// the pages are already in the reply.
|
|
2106
|
+
if (wantIndex && indexDb) {
|
|
2107
|
+
try {
|
|
2108
|
+
const slug = url.replace(/[^a-z0-9]+/gi, "-").slice(0, 120);
|
|
2109
|
+
const file = join(crawlDir, `${slug || "page"}.md`);
|
|
2110
|
+
writeFileSync(file, `# ${url}\n\n${doc.markdown}\n`);
|
|
2111
|
+
ctxIndex.indexFile(indexDb, file);
|
|
2112
|
+
indexed += 1;
|
|
2113
|
+
} catch {
|
|
2114
|
+
/* the page is in the reply; the index is the bonus */
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
1900
2117
|
if (depth < maxDepth) {
|
|
1901
2118
|
for (const link of await sameOriginLinks(page)) {
|
|
1902
2119
|
if (seen.has(link) || seen.size >= maxPages * 4) continue;
|
|
@@ -1909,7 +2126,12 @@ async function handleWeb(name, args) {
|
|
|
1909
2126
|
if (queue.length && out.length < maxPages) await page.waitForTimeout(delay);
|
|
1910
2127
|
}
|
|
1911
2128
|
resetRefs();
|
|
1912
|
-
|
|
2129
|
+
const indexNote = indexed
|
|
2130
|
+
? ` - ${indexed} indexed, searchable with context_search`
|
|
2131
|
+
: wantIndex && indexDb === null
|
|
2132
|
+
? " - the index could not be opened, so these pages are not searchable"
|
|
2133
|
+
: "";
|
|
2134
|
+
return `${out.length} page(s)${indexNote}\n\n${fenceUntrusted(`${out.length} page(s) from ${origin && origin !== "null" ? origin : start}`, out.join("\n\n"))}`;
|
|
1913
2135
|
}
|
|
1914
2136
|
|
|
1915
2137
|
case "web_press_key": {
|
|
@@ -2278,10 +2500,10 @@ async function handleMedia(name, args) {
|
|
|
2278
2500
|
//
|
|
2279
2501
|
// Unset means everything, which is the behaviour every existing consumer
|
|
2280
2502
|
// already has. The value is a comma-separated list of families: ios, android,
|
|
2281
|
-
// web, design, code, pass, agent, context, research, media. An unknown name is
|
|
2503
|
+
// web, design, code, pass, agent, context, research, media, security. An unknown name is
|
|
2282
2504
|
// reported on stderr rather than silently ignored, because a typo that quietly
|
|
2283
2505
|
// disables a family is worse than a noisy one.
|
|
2284
|
-
const ALL_CAPS = ["ios", "android", "web", "design", "code", "pass", "agent", "context", "research", "media"];
|
|
2506
|
+
const ALL_CAPS = ["ios", "android", "web", "design", "code", "pass", "agent", "context", "research", "media", "security"];
|
|
2285
2507
|
|
|
2286
2508
|
function enabledCaps() {
|
|
2287
2509
|
const raw = (process.env.MCP_TOOLKIT_CAPS || "").trim();
|
|
@@ -2322,6 +2544,7 @@ const ALL_TOOLS = [
|
|
|
2322
2544
|
...DESIGN_TOOLS,
|
|
2323
2545
|
...CODE_TOOLS,
|
|
2324
2546
|
...PASS_TOOLS,
|
|
2547
|
+
...SECURITY_TOOLS,
|
|
2325
2548
|
];
|
|
2326
2549
|
|
|
2327
2550
|
// Name -> inputSchema, so the CallTool boundary can enforce the declared shape.
|
|
@@ -2584,6 +2807,7 @@ const OUTPUT_SCHEMAS = {
|
|
|
2584
2807
|
// made all 78 unavailable in Claude Code - see the note further down.
|
|
2585
2808
|
...CODE_OUTPUT_SCHEMAS,
|
|
2586
2809
|
...PASS_OUTPUT_SCHEMAS,
|
|
2810
|
+
...SECURITY_OUTPUT_SCHEMAS,
|
|
2587
2811
|
ios_accessibility_audit: ACCESSIBILITY_AUDIT_SCHEMA,
|
|
2588
2812
|
android_accessibility_audit: ACCESSIBILITY_AUDIT_SCHEMA,
|
|
2589
2813
|
|
|
@@ -2798,6 +3022,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
|
2798
3022
|
// aborted call cancels the in-flight LSP request instead of orphaning it.
|
|
2799
3023
|
else if (name.startsWith("code_")) result = await handleCode(name, args || {}, ctx);
|
|
2800
3024
|
else if (name.startsWith("pass_")) result = await handlePass(name, args || {});
|
|
3025
|
+
else if (name.startsWith("security_")) result = await handleSecurity(name, args || {});
|
|
2801
3026
|
else return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
2802
3027
|
// Same reason as dispatchStep: a handler returns null only from its
|
|
2803
3028
|
// `default:` arm, so an unrecognised name that happens to carry a known
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmerterden/multi-agent-toolkit-mcp",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "MCP server for iOS Simulator, Android Emulator and headless web control.
|
|
3
|
+
"version": "3.14.0",
|
|
4
|
+
"description": "MCP server for iOS Simulator, Android Emulator and headless web control. 117 tools: device automation (tap/swipe/type), accessibility audits, visual diff, crash logs, App Store / Play Store pre-submission compliance, offline CVSS scoring + dependency inventory. Runs standalone over stdio with any MCP client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"start": "node index.js",
|
|
13
|
-
"test": "node --test tools/design-check/__tests__/design-check.test.mjs tools/design-check/__tests__/plan-determinism.test.mjs tools/ios-app-store-audit/__tests__/app-store-audit.test.mjs tools/ios-testflight/__tests__/testflight.test.mjs tools/ui-inspect/__tests__/ui-inspect.test.mjs tools/code-intel/__tests__/code-intel.test.mjs tools/pass-kit/__tests__/pass-kit.test.mjs tools/crash-logs/__tests__/crash-logs.test.mjs tools/a11y/__tests__/a11y.test.mjs tools/launch-time/__tests__/launch-time.test.mjs tools/memory/__tests__/memory.test.mjs tools/offload/__tests__/offload.test.mjs tools/context/__tests__/context.test.mjs __tests__/server-tools.test.mjs __tests__/injection.test.mjs",
|
|
13
|
+
"test": "node --test tools/design-check/__tests__/design-check.test.mjs tools/design-check/__tests__/plan-determinism.test.mjs tools/ios-app-store-audit/__tests__/app-store-audit.test.mjs tools/ios-testflight/__tests__/testflight.test.mjs tools/ui-inspect/__tests__/ui-inspect.test.mjs tools/code-intel/__tests__/code-intel.test.mjs tools/pass-kit/__tests__/pass-kit.test.mjs tools/crash-logs/__tests__/crash-logs.test.mjs tools/a11y/__tests__/a11y.test.mjs tools/launch-time/__tests__/launch-time.test.mjs tools/memory/__tests__/memory.test.mjs tools/offload/__tests__/offload.test.mjs tools/context/__tests__/context.test.mjs tools/security/__tests__/security.test.mjs __tests__/server-tools.test.mjs __tests__/injection.test.mjs",
|
|
14
14
|
"gates": "bash scripts/gates.sh"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
@@ -85,11 +85,24 @@
|
|
|
85
85
|
"pngjs": "^7.0.0"
|
|
86
86
|
},
|
|
87
87
|
"peerDependencies": {
|
|
88
|
-
"
|
|
88
|
+
"@mozilla/readability": "^0.6.0",
|
|
89
|
+
"playwright": ">=1.60.0 <2",
|
|
90
|
+
"turndown": "^7.2.0"
|
|
89
91
|
},
|
|
90
92
|
"peerDependenciesMeta": {
|
|
91
93
|
"playwright": {
|
|
92
94
|
"optional": true
|
|
95
|
+
},
|
|
96
|
+
"@mozilla/readability": {
|
|
97
|
+
"optional": true
|
|
98
|
+
},
|
|
99
|
+
"turndown": {
|
|
100
|
+
"optional": true
|
|
93
101
|
}
|
|
102
|
+
},
|
|
103
|
+
"devDependencies": {
|
|
104
|
+
"@mozilla/readability": "^0.6.0",
|
|
105
|
+
"playwright": "^1.63.0",
|
|
106
|
+
"turndown": "^7.2.4"
|
|
94
107
|
}
|
|
95
108
|
}
|
package/tools/context/index.js
CHANGED
|
@@ -108,30 +108,47 @@ export function indexFile(db, path) {
|
|
|
108
108
|
// a log line into the query gets a SQL error instead of results. Quoting each
|
|
109
109
|
// term makes every query a literal phrase search, which is what a caller
|
|
110
110
|
// searching a log actually means.
|
|
111
|
-
|
|
111
|
+
//
|
|
112
|
+
// Space between two FTS5 terms is AND. That is right for a log line, where
|
|
113
|
+
// every word is a fact about the one entry being looked for, and wrong for a
|
|
114
|
+
// question: "pack numbers into a bytes object using a format string" demands
|
|
115
|
+
// all nine words from a single chunk and returns nothing at all rather than
|
|
116
|
+
// the page that carries seven of them. `mode: "or"` builds the same query
|
|
117
|
+
// with OR, which is what `search` falls back to; bm25 then ranks by how many
|
|
118
|
+
// of the terms a chunk carries and how rare each one is.
|
|
119
|
+
export function toMatchQuery(query, { mode = "and" } = {}) {
|
|
112
120
|
const terms = String(query)
|
|
113
121
|
.split(/\s+/)
|
|
114
122
|
.map((t) => t.replace(/"/g, ""))
|
|
115
123
|
.filter(Boolean);
|
|
116
124
|
if (!terms.length) return null;
|
|
117
|
-
return terms.map((t) => `"${t}"`).join(" ");
|
|
125
|
+
return terms.map((t) => `"${t}"`).join(mode === "or" ? " OR " : " ");
|
|
118
126
|
}
|
|
119
127
|
|
|
120
128
|
export function search(db, query, { limit = 5, path = null } = {}) {
|
|
121
|
-
const match = toMatchQuery(query);
|
|
122
|
-
if (!match) return [];
|
|
123
129
|
const where = path
|
|
124
130
|
? "where chunks match ? and doc_id = (select id from docs where path = ?)"
|
|
125
131
|
: "where chunks match ?";
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
.all(...params, limit);
|
|
132
|
+
const sql = db.prepare(
|
|
133
|
+
`select chunks.rowid, doc_id, first_line, last_line, body,
|
|
134
|
+
(select path from docs where docs.id = chunks.doc_id) as path,
|
|
135
|
+
bm25(chunks) as score
|
|
136
|
+
from chunks ${where} order by score limit ?`,
|
|
137
|
+
);
|
|
138
|
+
const run = (match) => {
|
|
139
|
+
const params = path ? [match, path] : [match];
|
|
140
|
+
return sql.all(...params, limit);
|
|
141
|
+
};
|
|
142
|
+
// Every term first, because a caller who typed all of them meant all of
|
|
143
|
+
// them. Any term second, so a question that no single chunk answers word
|
|
144
|
+
// for word still comes back ranked instead of empty.
|
|
145
|
+
let rows = [];
|
|
146
|
+
for (const mode of ["and", "or"]) {
|
|
147
|
+
const match = toMatchQuery(query, { mode });
|
|
148
|
+
if (!match) return [];
|
|
149
|
+
rows = run(match);
|
|
150
|
+
if (rows.length) break;
|
|
151
|
+
}
|
|
135
152
|
return rows.map((r) => ({
|
|
136
153
|
id: r.rowid,
|
|
137
154
|
docId: r.doc_id,
|
package/tools/offload/index.js
CHANGED
|
@@ -142,6 +142,60 @@ export function offloadedErrorSummary(text, path, headChars = 400, tailChars = 2
|
|
|
142
142
|
);
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
// What a payload IS decides what is worth keeping out of it.
|
|
146
|
+
//
|
|
147
|
+
// A head-plus-tail window keeps the two places a large payload is least likely
|
|
148
|
+
// to carry its answer. A logcat's meaning is one FATAL line somewhere in the
|
|
149
|
+
// middle; a build log's is its `error:` lines; a UI dump's is the handful of
|
|
150
|
+
// nodes you can actually tap. The tool name is already here, so the window can
|
|
151
|
+
// be chosen rather than fixed - and the lines it surfaces come with their line
|
|
152
|
+
// numbers, so the caller can go straight to them in the saved file.
|
|
153
|
+
//
|
|
154
|
+
// Patterns only, no model: the cost of being wrong is a few extra lines in a
|
|
155
|
+
// window that already exists, which is the right trade for a dependency-free
|
|
156
|
+
// check that runs on every offload.
|
|
157
|
+
const SIGNAL_FAMILIES = [
|
|
158
|
+
{
|
|
159
|
+
when: /logcat|crash|crashes/i,
|
|
160
|
+
label: "crash",
|
|
161
|
+
rx: /\b(FATAL|AndroidRuntime|Exception|SIGABRT|SIGSEGV|SIGILL|Thread \d+ Crashed|Abort trap)\b/,
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
when: /xcodebuild|xcresult|build|test/i,
|
|
165
|
+
label: "failure",
|
|
166
|
+
rx: /(^|\s)(error:|FAILED|failed:|XCTAssert\w*|Undefined symbol|linker command failed|\*\* BUILD FAILED)/,
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
when: /ui_tree|accessibility|inspect/i,
|
|
170
|
+
label: "interactive",
|
|
171
|
+
rx: /\b(AXButton|AXTextField|AXSwitch|clickable=true|enabled=true|focusable=true)\b/,
|
|
172
|
+
},
|
|
173
|
+
{ when: /leaks|meminfo|memory/i, label: "measurement", rx: /\b(Leak|leaked|TOTAL|Total PSS|bytes)\b/ },
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
export const SIGNAL_LINE_CAP = 20;
|
|
177
|
+
const SIGNAL_LINE_CHARS = 200;
|
|
178
|
+
|
|
179
|
+
// Lines from the part the window hides, that carry what this kind of payload is
|
|
180
|
+
// read for. Returns "" when the family is unknown or nothing matched, so the
|
|
181
|
+
// window is exactly what it was before.
|
|
182
|
+
export function signalLines(tool, lines, headCount, tailCount, cap = SIGNAL_LINE_CAP) {
|
|
183
|
+
const family = SIGNAL_FAMILIES.find((f) => f.when.test(String(tool)));
|
|
184
|
+
if (!family) return "";
|
|
185
|
+
const from = headCount;
|
|
186
|
+
const to = Math.max(headCount, lines.length - tailCount);
|
|
187
|
+
const hits = [];
|
|
188
|
+
for (let i = from; i < to && hits.length < cap; i++) {
|
|
189
|
+
if (family.rx.test(lines[i])) {
|
|
190
|
+
const body = lines[i].length > SIGNAL_LINE_CHARS ? `${lines[i].slice(0, SIGNAL_LINE_CHARS)}...` : lines[i];
|
|
191
|
+
hits.push(` ${i + 1}: ${body}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!hits.length) return "";
|
|
195
|
+
const more = hits.length === cap ? ` (first ${cap})` : "";
|
|
196
|
+
return `\n\n[${family.label} lines from the hidden middle${more}]\n${hits.join("\n")}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
145
199
|
export function offloadLargeText(tool, text, opts = {}) {
|
|
146
200
|
const dir = opts.dir ?? OFFLOAD_DIR;
|
|
147
201
|
const minChars = opts.minChars ?? OFFLOAD_MIN_CHARS;
|
|
@@ -166,13 +220,15 @@ export function offloadLargeText(tool, text, opts = {}) {
|
|
|
166
220
|
const head = lines.slice(0, HEAD_LINES).join("\n");
|
|
167
221
|
const tail = lines.slice(-TAIL_LINES).join("\n");
|
|
168
222
|
const hidden = Math.max(0, lines.length - HEAD_LINES - TAIL_LINES);
|
|
223
|
+
const signal = signalLines(tool, lines, HEAD_LINES, TAIL_LINES);
|
|
169
224
|
return {
|
|
170
225
|
offloaded: true,
|
|
171
226
|
path,
|
|
172
227
|
text:
|
|
173
228
|
`${head}\n\n[... ${hidden} line(s) not shown. Full output (${lines.length} lines, ` +
|
|
174
229
|
`${text.length} bytes) saved to ${path} - search it with ` +
|
|
175
|
-
`agent_query_output {pattern: "..."} instead of re-running this tool ...]
|
|
230
|
+
`agent_query_output {pattern: "..."} instead of re-running this tool ...]` +
|
|
231
|
+
`${signal}\n\n${tail}`,
|
|
176
232
|
};
|
|
177
233
|
}
|
|
178
234
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CVSS 3.1 base-score computation. Pure arithmetic from the specification
|
|
3
|
+
* (https://www.first.org/cvss/v3.1/specification-document), no dependency and
|
|
4
|
+
* no network. Given a base vector string it returns the base score and the
|
|
5
|
+
* qualitative band, which is what the pipeline maps onto its reviewer severity.
|
|
6
|
+
*
|
|
7
|
+
* @module tools/security/cvss
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const AV = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 };
|
|
11
|
+
const AC = { L: 0.77, H: 0.44 };
|
|
12
|
+
const UI = { N: 0.85, R: 0.62 };
|
|
13
|
+
// Privileges Required depends on Scope: the Changed column is the second entry.
|
|
14
|
+
const PR = { N: [0.85, 0.85], L: [0.62, 0.68], H: [0.27, 0.5] };
|
|
15
|
+
const CIA = { H: 0.56, L: 0.22, N: 0 };
|
|
16
|
+
|
|
17
|
+
const BASE_METRICS = ["AV", "AC", "PR", "UI", "S", "C", "I", "A"];
|
|
18
|
+
const ALLOWED = {
|
|
19
|
+
AV: ["N", "A", "L", "P"],
|
|
20
|
+
AC: ["L", "H"],
|
|
21
|
+
PR: ["N", "L", "H"],
|
|
22
|
+
UI: ["N", "R"],
|
|
23
|
+
S: ["U", "C"],
|
|
24
|
+
C: ["N", "L", "H"],
|
|
25
|
+
I: ["N", "L", "H"],
|
|
26
|
+
A: ["N", "L", "H"],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* CVSS 3.1 Roundup: the smallest 1-decimal number >= input, computed in integer
|
|
31
|
+
* space so float representation cannot round 4.0 up to 4.1. This is the exact
|
|
32
|
+
* algorithm from the specification's appendix.
|
|
33
|
+
* @param {number} input
|
|
34
|
+
* @returns {number}
|
|
35
|
+
*/
|
|
36
|
+
export function roundup(input) {
|
|
37
|
+
const intInput = Math.round(input * 100000);
|
|
38
|
+
if (intInput % 10000 === 0) return intInput / 100000;
|
|
39
|
+
return (Math.floor(intInput / 10000) + 1) / 10;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {number} score
|
|
44
|
+
* @returns {"none"|"low"|"medium"|"high"|"critical"}
|
|
45
|
+
*/
|
|
46
|
+
export function band(score) {
|
|
47
|
+
if (score === 0) return "none";
|
|
48
|
+
if (score <= 3.9) return "low";
|
|
49
|
+
if (score <= 6.9) return "medium";
|
|
50
|
+
if (score <= 8.9) return "high";
|
|
51
|
+
return "critical";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse a CVSS 3.1 base vector into its metric map, validating the prefix, that
|
|
56
|
+
* every base metric is present exactly once, and that each value is legal.
|
|
57
|
+
* @param {string} vector
|
|
58
|
+
* @returns {{ metrics: Record<string,string> } | { error: string }}
|
|
59
|
+
*/
|
|
60
|
+
export function parseVector(vector) {
|
|
61
|
+
if (typeof vector !== "string" || !vector.trim()) return { error: "vector is required" };
|
|
62
|
+
const parts = vector.trim().split("/");
|
|
63
|
+
if (parts[0] !== "CVSS:3.1") return { error: `vector must start with CVSS:3.1, got "${parts[0]}"` };
|
|
64
|
+
const metrics = {};
|
|
65
|
+
for (const part of parts.slice(1)) {
|
|
66
|
+
const [key, value] = part.split(":");
|
|
67
|
+
if (!key || value === undefined) return { error: `malformed metric segment "${part}"` };
|
|
68
|
+
if (metrics[key] !== undefined) return { error: `metric ${key} appears more than once` };
|
|
69
|
+
metrics[key] = value;
|
|
70
|
+
}
|
|
71
|
+
for (const m of BASE_METRICS) {
|
|
72
|
+
if (metrics[m] === undefined) return { error: `missing base metric ${m}` };
|
|
73
|
+
if (!ALLOWED[m].includes(metrics[m])) {
|
|
74
|
+
return { error: `illegal value ${m}:${metrics[m]} (allowed: ${ALLOWED[m].join("/")})` };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { metrics };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Compute the CVSS 3.1 base score and band from a base vector.
|
|
82
|
+
* @param {string} vector
|
|
83
|
+
* @returns {{ vector: string, baseScore: number, band: string } | { error: string }}
|
|
84
|
+
*/
|
|
85
|
+
export function scoreVector(vector) {
|
|
86
|
+
const parsed = parseVector(vector);
|
|
87
|
+
if ("error" in parsed) return parsed;
|
|
88
|
+
const m = parsed.metrics;
|
|
89
|
+
const scopeChanged = m.S === "C";
|
|
90
|
+
|
|
91
|
+
const iss = 1 - (1 - CIA[m.C]) * (1 - CIA[m.I]) * (1 - CIA[m.A]);
|
|
92
|
+
const impact = scopeChanged
|
|
93
|
+
? 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15)
|
|
94
|
+
: 6.42 * iss;
|
|
95
|
+
|
|
96
|
+
const exploitability = 8.22 * AV[m.AV] * AC[m.AC] * PR[m.PR][scopeChanged ? 1 : 0] * UI[m.UI];
|
|
97
|
+
|
|
98
|
+
let baseScore;
|
|
99
|
+
if (impact <= 0) {
|
|
100
|
+
baseScore = 0;
|
|
101
|
+
} else if (scopeChanged) {
|
|
102
|
+
baseScore = roundup(Math.min(1.08 * (impact + exploitability), 10));
|
|
103
|
+
} else {
|
|
104
|
+
baseScore = roundup(Math.min(impact + exploitability, 10));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { vector: vector.trim(), baseScore, band: band(baseScore) };
|
|
108
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* security - local, offline helpers for a static security review.
|
|
3
|
+
*
|
|
4
|
+
* OFFLINE BY CONSTRUCTION. Neither tool contacts a registry, an advisory
|
|
5
|
+
* database or anything else: security_cvss_score is pure arithmetic, and
|
|
6
|
+
* security_dep_inventory reads one lockfile off disk and normalizes it. Matching
|
|
7
|
+
* an inventory against known CVEs is a separate, network-bearing step
|
|
8
|
+
* (ai-analyst-toolkit:evidence-registry) kept out of this family on purpose, so
|
|
9
|
+
* the toolkit's stdio-only egress allowlist does not change.
|
|
10
|
+
*
|
|
11
|
+
* GENERIC. No project, brand or repo appears here. Every input is a caller value.
|
|
12
|
+
*
|
|
13
|
+
* @module tools/security
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { scoreVector } from "./cvss.js";
|
|
17
|
+
import { inventory, SUPPORTED } from "./deps.js";
|
|
18
|
+
|
|
19
|
+
const ERROR_PREFIX = "ERROR: ";
|
|
20
|
+
|
|
21
|
+
export const SECURITY_TOOLS = [
|
|
22
|
+
{
|
|
23
|
+
name: "security_cvss_score",
|
|
24
|
+
description:
|
|
25
|
+
"Compute the CVSS 3.1 base score and qualitative band from a base vector string, so a finding's score cannot drift from its vector by hand. Pure arithmetic from the FIRST specification, offline. Returns { vector, baseScore, band } where band is none|low|medium|high|critical - the value the pipeline maps to its reviewer severity (critical/high -> blocking, medium -> important, low/none -> suggestion).",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
additionalProperties: false,
|
|
29
|
+
required: ["vector"],
|
|
30
|
+
properties: {
|
|
31
|
+
vector: {
|
|
32
|
+
type: "string",
|
|
33
|
+
description:
|
|
34
|
+
"A full CVSS 3.1 base vector, e.g. CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H. All eight base metrics required; temporal and environmental metrics are ignored.",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "security_dep_inventory",
|
|
41
|
+
description:
|
|
42
|
+
"Parse a dependency lockfile off disk into a normalized inventory of { ecosystem, name, version }. Local parse only - contacts nothing. Supported lockfiles: " +
|
|
43
|
+
Object.keys(SUPPORTED).join(", ") +
|
|
44
|
+
". Hand the inventory to a CVE lookup (ai-analyst-toolkit:evidence-registry) as a separate step; this tool never reaches the network.",
|
|
45
|
+
inputSchema: {
|
|
46
|
+
type: "object",
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
required: ["path"],
|
|
49
|
+
properties: {
|
|
50
|
+
path: {
|
|
51
|
+
type: "string",
|
|
52
|
+
description:
|
|
53
|
+
"Absolute path to a supported lockfile (its basename decides the parser, e.g. package-lock.json, Podfile.lock, requirements.txt, go.sum).",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
export const SECURITY_OUTPUT_SCHEMAS = {
|
|
61
|
+
security_cvss_score: {
|
|
62
|
+
type: "object",
|
|
63
|
+
required: ["vector", "baseScore", "band"],
|
|
64
|
+
properties: {
|
|
65
|
+
vector: { type: "string" },
|
|
66
|
+
baseScore: { type: "number" },
|
|
67
|
+
band: { type: "string", enum: ["none", "low", "medium", "high", "critical"] },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
security_dep_inventory: {
|
|
71
|
+
type: "object",
|
|
72
|
+
required: ["ecosystem", "file", "count", "dependencies"],
|
|
73
|
+
properties: {
|
|
74
|
+
ecosystem: { type: "string" },
|
|
75
|
+
file: { type: "string" },
|
|
76
|
+
count: { type: "integer" },
|
|
77
|
+
dependencies: {
|
|
78
|
+
type: "array",
|
|
79
|
+
items: {
|
|
80
|
+
type: "object",
|
|
81
|
+
required: ["ecosystem", "name", "version"],
|
|
82
|
+
properties: {
|
|
83
|
+
ecosystem: { type: "string" },
|
|
84
|
+
name: { type: "string" },
|
|
85
|
+
version: { type: "string" },
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {string} name
|
|
95
|
+
* @param {Record<string, unknown>} args
|
|
96
|
+
* @returns {Promise<string|null>} JSON string result, or an ERROR: string, or null for an unknown name.
|
|
97
|
+
*/
|
|
98
|
+
export async function handleSecurity(name, args = {}) {
|
|
99
|
+
try {
|
|
100
|
+
switch (name) {
|
|
101
|
+
case "security_cvss_score": {
|
|
102
|
+
const r = scoreVector(args.vector);
|
|
103
|
+
return "error" in r ? `${ERROR_PREFIX}${r.error}` : JSON.stringify(r);
|
|
104
|
+
}
|
|
105
|
+
case "security_dep_inventory": {
|
|
106
|
+
const r = inventory(args.path);
|
|
107
|
+
return "error" in r ? `${ERROR_PREFIX}${r.error}` : JSON.stringify(r);
|
|
108
|
+
}
|
|
109
|
+
default:
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
} catch (e) {
|
|
113
|
+
return `${ERROR_PREFIX}${name}: ${e?.message || String(e)}`;
|
|
114
|
+
}
|
|
115
|
+
}
|