mcp-app-lint 0.1.5 → 0.1.6
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/README.md +28 -3
- package/dist/a2ui.d.mts +28 -0
- package/dist/a2ui.d.mts.map +1 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.d.mts.map +1 -1
- package/dist/index.d.mts +3 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/rules.d.mts +4 -2
- package/dist/rules.d.mts.map +1 -1
- package/dist/sarif.d.mts +1 -1
- package/package.json +3 -2
- package/src/a2ui.mjs +360 -0
- package/src/cli.mjs +9 -2
- package/src/index.mjs +2 -0
- package/src/rules.mjs +12 -2
- package/src/sarif.mjs +1 -1
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ npx mcp-app-lint --package @scope/name # an npm package: download
|
|
|
9
9
|
npx mcp-app-lint --read read.json --list list.json --tools tools.json
|
|
10
10
|
# results of resources/read, resources/list, tools/list
|
|
11
11
|
npx mcp-app-lint --html app.html # one UI document
|
|
12
|
+
npx mcp-app-lint --a2ui stream.jsonl # A2UI v0.9 messages: one, a JSON array, { messages } or JSONL
|
|
12
13
|
```
|
|
13
14
|
|
|
14
15
|
Options: `--out file.sarif`, `--format sarif|text`, `--fail-on error|warning|note|none` (default
|
|
@@ -41,7 +42,8 @@ Inputs: `directory` (default `.`), `fail-on` (default `error`), `upload-sarif` (
|
|
|
41
42
|
`sarif-file` (default `mcp-app-lint.sarif`), `version` (npm dist-tag or version, default `latest`),
|
|
42
43
|
`args` (passed through), `node-version` (default `22`, empty to use the runner's Node). The SARIF
|
|
43
44
|
is written even when findings fail the job, so it still uploads; paths are repo-relative so code
|
|
44
|
-
scanning maps them to the source.
|
|
45
|
+
scanning maps them to the source. Recorded A2UI streams go through `args`, for example
|
|
46
|
+
`args: --a2ui test/fixtures/surface.jsonl`.
|
|
45
47
|
|
|
46
48
|
Any CI can run the CLI directly and read the exit code (`0` clean, `1` findings at or above
|
|
47
49
|
`--fail-on`, `2` usage error):
|
|
@@ -74,6 +76,29 @@ npx mcp-app-lint --dir . --format sarif --out mcp-app-lint.sarif --fail-on error
|
|
|
74
76
|
| MCPAPP018 | meta-read-wider-than-list | error | both declare a CSP and the read-time one adds an entry (domain or wildcard) the listing did not declare under the same key |
|
|
75
77
|
| MCPAPP019 | csp-sink-tenant-host | note | one bucket, worker or app on a storage or serverless service from the denylist (`acct.blob.core.windows.net`, `pub-<id>.r2.dev`); fine while the owner keeps the name |
|
|
76
78
|
|
|
79
|
+
### A2UI rules
|
|
80
|
+
|
|
81
|
+
`--a2ui` reads A2UI v0.9 server-to-client messages (`createSurface`, `updateComponents`,
|
|
82
|
+
`updateDataModel`, `deleteSurface`). A2UI sends no HTML; what matters is where the agent's data may
|
|
83
|
+
flow. Absolute `{ "path": … }` bindings and `formatString` interpolations are resolved against the
|
|
84
|
+
`updateDataModel` messages of the same stream after every message, so a value swapped after the
|
|
85
|
+
first render is checked too, at the line of the update that brought it. Relative paths in list
|
|
86
|
+
templates and client function calls are left to the runtime check
|
|
87
|
+
([`@render-policy/a2ui`](../a2ui)); this scan is a CI aid, not a substitute for it.
|
|
88
|
+
|
|
89
|
+
| Id | Name | Level | What it means |
|
|
90
|
+
| --- | --- | --- | --- |
|
|
91
|
+
| A2UI001 | a2ui-openurl-scheme | error | `openUrl` target that does not resolve to http or https (`javascript:`, `data:`, `mailto:`), as the basic catalog requires |
|
|
92
|
+
| A2UI002 | a2ui-sink-host | error | `Image`, `Video` or `AudioPlayer` url, `theme.iconUrl` or an `openUrl` target on a sink host anyone can use |
|
|
93
|
+
| A2UI003 | a2ui-url-encoded-payload | warning | media or icon URL, fetched without a click, that is too long or carries a long high-entropy token |
|
|
94
|
+
| A2UI004 | a2ui-url-template-host | warning | `formatString` takes the scheme or host of a media URL or `openUrl` target from data |
|
|
95
|
+
| A2UI005 | a2ui-url-template-data | note | `formatString` puts data into the path or query of a media URL |
|
|
96
|
+
| A2UI006 | a2ui-text-markup | warning | `Text` contains raw HTML, a Markdown image or a link (bare URLs count; code spans and fences do not), which the basic catalog excludes |
|
|
97
|
+
| A2UI007 | a2ui-remote-media-hosts | note | the hosts media and icons load from, for the client's allowlist |
|
|
98
|
+
|
|
99
|
+
On the 44 examples of the v0.9 basic catalog the scan reports A2UI007 notes and one A2UI006: the
|
|
100
|
+
Markdown example puts a link in `Text`.
|
|
101
|
+
|
|
77
102
|
Hand-written means an inline script with short lines and not too many of them; bundles carry
|
|
78
103
|
framework internals and the SDK bridge (which posts to `'*'` by design) and are not reported
|
|
79
104
|
for MCPAPP010, MCPAPP011 and MCPAPP013.
|
|
@@ -86,10 +111,10 @@ Apps MIME type or `ui://` (hosts, renderers, SDKs) are not treated as serving a
|
|
|
86
111
|
looked for in the package's HTML documents only: a `fetch` in server code is not bound by the CSP,
|
|
87
112
|
so it does not count.
|
|
88
113
|
|
|
89
|
-
The rules come out of the [MCP Apps UI census](../../census) in this repository; the analyzer is
|
|
114
|
+
The MCP Apps rules come out of the [MCP Apps UI census](../../census) in this repository; the analyzer is
|
|
90
115
|
shared. The HTML sink detection is `eslint-plugin-render-policy`'s `no-unsafe-innerhtml`. The sink
|
|
91
116
|
denylist is `@render-policy/core`'s. The package also exports the analysis functions (`analyzeHtml`,
|
|
92
|
-
`analyzeUiMeta`, `compareListRead`, `scanPackageDir`, …) and `categorizeDomain`, the heuristic host
|
|
117
|
+
`analyzeUiMeta`, `compareListRead`, `scanPackageDir`, `lintA2uiMessages`, `parseA2uiMessages`, …) and `categorizeDomain`, the heuristic host
|
|
93
118
|
category (fonts, analytics, maps, storage, media, cdn, api, …) the census report tabulates; it is
|
|
94
119
|
data for aggregates, not a rule.
|
|
95
120
|
|
package/dist/a2ui.d.mts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Finding } from './lint.mjs';
|
|
2
|
+
export type A2uiEntry = {
|
|
3
|
+
message: unknown;
|
|
4
|
+
line?: number;
|
|
5
|
+
};
|
|
6
|
+
export type UrlUse = 'fetched' | 'openUrl';
|
|
7
|
+
export type Place = {
|
|
8
|
+
surfaceId: string;
|
|
9
|
+
componentId?: string;
|
|
10
|
+
property: string;
|
|
11
|
+
dataPath?: string;
|
|
12
|
+
line?: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Messages from the text of a file: one message, an array of them, an object with `messages` (the
|
|
16
|
+
* list wrapper and the specification's samples), or JSONL. Throws on a line that does not parse.
|
|
17
|
+
* @param {string} text
|
|
18
|
+
* @returns {A2uiEntry[]}
|
|
19
|
+
*/
|
|
20
|
+
export declare function parseA2uiMessages(text: string): A2uiEntry[];
|
|
21
|
+
/**
|
|
22
|
+
* Findings over a stream of A2UI v0.9 messages. `uri` is the file the messages came from.
|
|
23
|
+
* @param {readonly (A2uiEntry | unknown)[]} input messages, or entries with their line
|
|
24
|
+
* @param {string} uri
|
|
25
|
+
* @returns {Finding[]}
|
|
26
|
+
*/
|
|
27
|
+
export declare function lintA2uiMessages(input: readonly (A2uiEntry | unknown)[], uri: string): Finding[];
|
|
28
|
+
//# sourceMappingURL=a2ui.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"a2ui.d.mts","sourceRoot":"","sources":["../src/a2ui.mjs"],"names":[],"mappings":"AAiBG,OAAQ,KAAA,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;AAIrC,YAA+C,SAAS,GAA9C;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAW;AAEvD,YAAiC,MAAM,GAA7B,SAAS,GAAG,SAAS,CAAQ;AAGxC,YAA2G,KAAK,GAAtG;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAO;AAmBnH;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAH3B,MAG2B,GAFzB,SAAS,EAAE,CA4BvB;AA4GD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAJ3B,SAAS,CAAC,SAAS,GAAG,OAAO,CAAC,EAIH,EAAE,GAAG,EAHhC,MAGgC,GAF9B,OAAO,EAAE,CA0KrB"}
|
package/dist/cli.d.mts
CHANGED
package/dist/cli.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.mts","sourceRoot":"","sources":["../src/cli.mjs"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"cli.d.mts","sourceRoot":"","sources":["../src/cli.mjs"],"names":[],"mappings":"AAqBG,OAAQ,KAAA,EAAE,QAAQ,EAAE,MAAM,aAAa,CACvC;AAGA,YAAkB,UAAU,GAC5B;IAAmB,MAAM,EAAd,MAAM,CACjB;IAAmB,MAAM,EAAd,MAAM,CACjB;IAAoB,GAAG,AAAvB,CACA,EADW,MAAM,CACjB;IAAoB,OAAO,AAA3B,CACA,EADW,MAAM,CACjB;IAAoB,IAAI,AAAxB,CACA,EADW,MAAM,CACjB;IAAoB,IAAI,AAAxB,CACA,EADW,MAAM,CACjB;IAAoB,KAAK,AAAzB,CACA,EADW,MAAM,CACjB;IAAoB,IAAI,AAAxB,CACA,EADW,MAAM,CACjB;IAAoB,IAAI,AAAxB,CACA,EADW,MAAM,CACjB;IAAoB,GAAG,AAAvB,CACA,EADW,MAAM,CACjB;IAAqB,IAAI,AAAzB,CAA0B,EAAf,OAAO,CAAQ;CAAA,CAAA;AAgD7B;;;;;;GAMG;AACH,wBAAsB,GAAG,CAAC,IAAI,EAJnB,SAAS,MAAM,EAII,EAAE,EAAE,MAAuB,EAAE,MAAuB,EAAE,AAHjF,CACA,EADQ;IAAE,MAAM,CAAC,EAAE,IAAI,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;IAAC,MAAM,CAAC,EAAE,IAAI,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAA;CAGU,GAF5E,OAAO,CAAC,MAAM,CAAC,CA2D3B"}
|
package/dist/index.d.mts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { lintA2uiMessages, parseA2uiMessages } from './a2ui.mjs';
|
|
1
2
|
export { analyzeHtml, analyzeTools, analyzeUiMeta, compareListRead, ESCAPE_FUNCTIONS, isHandwritten, postMessageStarCalls, UI_MIME, uiMetaOf, withSinkHosts } from './analyze.mjs';
|
|
2
3
|
export { categorizeDomain, categorizeHost, classifyDomains, DOMAIN_CATEGORIES, parseDomainPattern, sinkFor, sinkScope } from './domains.mjs';
|
|
3
4
|
export { levelOf, lintHtml, lintPackageScan, lintResource, lintTools, lintUiMeta } from './lint.mjs';
|
|
@@ -5,6 +6,7 @@ export { downloadTarball, extractTarball, packageMeta, scanPackage, scanPackageD
|
|
|
5
6
|
export { RULES, RULE_INDEX, ruleByName } from './rules.mjs';
|
|
6
7
|
export { toSarif, toText, TOOL_VERSION } from './sarif.mjs';
|
|
7
8
|
export { run } from './cli.mjs';
|
|
9
|
+
export type A2uiEntry = import('./a2ui.mjs').A2uiEntry;
|
|
8
10
|
export type UiMeta = import('./analyze.mjs').UiMeta;
|
|
9
11
|
export type CspKey = import('./analyze.mjs').CspKey;
|
|
10
12
|
export type HtmlAnalysis = import('./analyze.mjs').HtmlAnalysis;
|
|
@@ -23,6 +25,7 @@ export type DomainSite = import('./npm.mjs').DomainSite;
|
|
|
23
25
|
export type Rule = import('./rules.mjs').Rule;
|
|
24
26
|
export type Level = import('./rules.mjs').Level;
|
|
25
27
|
/**
|
|
28
|
+
* @typedef {import('./a2ui.mjs').A2uiEntry} A2uiEntry
|
|
26
29
|
* @typedef {import('./analyze.mjs').UiMeta} UiMeta
|
|
27
30
|
* @typedef {import('./analyze.mjs').CspKey} CspKey
|
|
28
31
|
* @typedef {import('./analyze.mjs').HtmlAnalysis} HtmlAnalysis
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mjs"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,gBAAgB,EAAE,aAAa,EAAE,oBAAoB,EAAE,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AACnL,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,eAAe,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAC7I,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,YAAY,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACrG,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,WAAW,EAAE,WAAW,EAAE,cAAc,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC/H,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC5D,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC5D,OAAO,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAG7B,YAA0C,MAAM,GAAtC,OAAO,eAAe,EAAE,MAAM,CAAQ;AAChD,YAA0C,MAAM,GAAtC,OAAO,eAAe,EAAE,MAAM,CAAQ;AAChD,YAAgD,YAAY,GAAlD,OAAO,eAAe,EAAE,YAAY,CAAc;AAC5D,YAA4C,QAAQ,GAA1C,OAAO,eAAe,EAAE,QAAQ,CAAU;AACpD,YAAmD,eAAe,GAAxD,OAAO,eAAe,EAAE,eAAe,CAAiB;AAClE,YAAiD,aAAa,GAApD,OAAO,eAAe,EAAE,aAAa,CAAe;AAC9D,YAAkD,cAAc,GAAtD,OAAO,eAAe,EAAE,cAAc,CAAgB;AAChE,YAAqD,iBAAiB,GAA5D,OAAO,eAAe,EAAE,iBAAiB,CAAmB;AACtE,YAA6C,SAAS,GAA5C,OAAO,eAAe,EAAE,SAAS,CAAW;AACtD,YAAwC,OAAO,GAArC,OAAO,YAAY,EAAE,OAAO,CAAS;AAC/C,YAA8C,aAAa,GAAjD,OAAO,YAAY,EAAE,aAAa,CAAe;AAC3D,YAA2C,WAAW,GAA5C,OAAO,WAAW,EAAE,WAAW,CAAa;AACtD,YAA2C,WAAW,GAA5C,OAAO,WAAW,EAAE,WAAW,CAAa;AACtD,YAA2C,WAAW,GAA5C,OAAO,WAAW,EAAE,WAAW,CAAa;AACtD,YAA0C,UAAU,GAA1C,OAAO,WAAW,EAAE,UAAU,CAAY;AACpD,YAAsC,IAAI,GAAhC,OAAO,aAAa,EAAE,IAAI,CAAM;AAC1C,YAAuC,KAAK,GAAlC,OAAO,aAAa,EAAE,KAAK,CAAO;
|
|
1
|
+
{"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mjs"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AACjE,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,gBAAgB,EAAE,aAAa,EAAE,oBAAoB,EAAE,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AACnL,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,eAAe,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAC7I,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,YAAY,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACrG,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,WAAW,EAAE,WAAW,EAAE,cAAc,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC/H,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC5D,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC5D,OAAO,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAG7B,YAA0C,SAAS,GAAzC,OAAO,YAAY,EAAE,SAAS,CAAW;AACnD,YAA0C,MAAM,GAAtC,OAAO,eAAe,EAAE,MAAM,CAAQ;AAChD,YAA0C,MAAM,GAAtC,OAAO,eAAe,EAAE,MAAM,CAAQ;AAChD,YAAgD,YAAY,GAAlD,OAAO,eAAe,EAAE,YAAY,CAAc;AAC5D,YAA4C,QAAQ,GAA1C,OAAO,eAAe,EAAE,QAAQ,CAAU;AACpD,YAAmD,eAAe,GAAxD,OAAO,eAAe,EAAE,eAAe,CAAiB;AAClE,YAAiD,aAAa,GAApD,OAAO,eAAe,EAAE,aAAa,CAAe;AAC9D,YAAkD,cAAc,GAAtD,OAAO,eAAe,EAAE,cAAc,CAAgB;AAChE,YAAqD,iBAAiB,GAA5D,OAAO,eAAe,EAAE,iBAAiB,CAAmB;AACtE,YAA6C,SAAS,GAA5C,OAAO,eAAe,EAAE,SAAS,CAAW;AACtD,YAAwC,OAAO,GAArC,OAAO,YAAY,EAAE,OAAO,CAAS;AAC/C,YAA8C,aAAa,GAAjD,OAAO,YAAY,EAAE,aAAa,CAAe;AAC3D,YAA2C,WAAW,GAA5C,OAAO,WAAW,EAAE,WAAW,CAAa;AACtD,YAA2C,WAAW,GAA5C,OAAO,WAAW,EAAE,WAAW,CAAa;AACtD,YAA2C,WAAW,GAA5C,OAAO,WAAW,EAAE,WAAW,CAAa;AACtD,YAA0C,UAAU,GAA1C,OAAO,WAAW,EAAE,UAAU,CAAY;AACpD,YAAsC,IAAI,GAAhC,OAAO,aAAa,EAAE,IAAI,CAAM;AAC1C,YAAuC,KAAK,GAAlC,OAAO,aAAa,EAAE,KAAK,CAAO;AAlB/C;;;;;;;;;;;;;;;;;;;GAmBG"}
|
package/dist/rules.d.mts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/** @typedef {'error' | 'warning' | 'note'} Level */
|
|
2
2
|
/**
|
|
3
|
-
* A rule in SARIF terms: `short` and `full` become its short and full description
|
|
4
|
-
*
|
|
3
|
+
* A rule in SARIF terms: `short` and `full` become its short and full description; `tags` default to
|
|
4
|
+
* security and mcp-apps.
|
|
5
|
+
* @typedef {{ id: string, name: string, level: Level, short: string, full: string, tags?: readonly string[] }} Rule
|
|
5
6
|
*/
|
|
6
7
|
export type Level = 'error' | 'warning' | 'note';
|
|
7
8
|
export type Rule = {
|
|
@@ -10,6 +11,7 @@ export type Rule = {
|
|
|
10
11
|
level: Level;
|
|
11
12
|
short: string;
|
|
12
13
|
full: string;
|
|
14
|
+
tags?: readonly string[];
|
|
13
15
|
};
|
|
14
16
|
/**
|
|
15
17
|
* The rules, in SARIF terms. Ids are stable; names are what a scanner shows.
|
package/dist/rules.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rules.d.mts","sourceRoot":"","sources":["../src/rules.mjs"],"names":[],"mappings":"AAAA,oDAAoD;AACpD
|
|
1
|
+
{"version":3,"file":"rules.d.mts","sourceRoot":"","sources":["../src/rules.mjs"],"names":[],"mappings":"AAAA,oDAAoD;AACpD;;;;GAIG;AALC,YAAwC,KAAK,GAAnC,OAAO,GAAG,SAAS,GAAG,MAAM,CAAO;AAI9C,YAA6G,IAAI,GAAvG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,CAAM;AAKpH;;;GAGG;AACH,eAAO,MAAM,KAAK,EAFR,SAAS,IAAI,EA6BtB,CAAC;AAEF,eAAO,MAAM,UAAU,qBAAwD,CAAC;AAChF,2BAA2B;AAC3B,eAAO,MAAM,UAAU,SADX,MAAM,qBACoD,CAAC"}
|
package/dist/sarif.d.mts
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-app-lint",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "SARIF findings about what an MCP App declares (CSP domain lists, permissions, tool visibility) and what its HTML does (innerHTML sinks, postMessage to '*', external hosts, data sinks).",
|
|
3
|
+
"version": "0.1.6",
|
|
4
|
+
"description": "SARIF findings about what an MCP App declares (CSP domain lists, permissions, tool visibility) and what its HTML does (innerHTML sinks, postMessage to '*', external hosts, data sinks); A2UI message streams (openUrl schemes, sink hosts, data in URLs, markup in Text).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"keywords": [
|
|
40
40
|
"mcp",
|
|
41
41
|
"mcp-apps",
|
|
42
|
+
"a2ui",
|
|
42
43
|
"sarif",
|
|
43
44
|
"security",
|
|
44
45
|
"csp",
|
package/src/a2ui.mjs
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static checks over A2UI v0.9 server-to-client messages (createSurface, updateComponents,
|
|
3
|
+
* updateDataModel, deleteSurface). A2UI sends no HTML; the hazards are URLs the client fetches
|
|
4
|
+
* without a click (Image, Video, AudioPlayer, theme.iconUrl), URLs it navigates to (openUrl), and
|
|
5
|
+
* Markdown in Text that breaks the basic catalog's "no HTML, images or links".
|
|
6
|
+
*
|
|
7
|
+
* Most values are bound to the data model, so the effective value exists only at render time. This
|
|
8
|
+
* scan resolves absolute `{ "path": … }` bindings and `formatString` interpolations against the
|
|
9
|
+
* `updateDataModel` messages of the same stream, after every message, so a value swapped after the
|
|
10
|
+
* first render is seen too. What it cannot resolve (relative paths in list templates, client
|
|
11
|
+
* functions, data from a later stream) it leaves to the runtime check, `@render-policy/a2ui`.
|
|
12
|
+
*/
|
|
13
|
+
import { checkUrl, DEFAULT_URL_HEURISTICS } from '@render-policy/core';
|
|
14
|
+
import { checkUrlHeuristics } from '@render-policy/core/internal';
|
|
15
|
+
import { parseDomainPattern, sinkScope } from './domains.mjs';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @import { Finding } from './lint.mjs'
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* One message and the line it starts on, when the input had lines (JSONL).
|
|
22
|
+
* @typedef {{ message: unknown, line?: number }} A2uiEntry
|
|
23
|
+
*/
|
|
24
|
+
/** @typedef {'fetched' | 'openUrl'} UrlUse */
|
|
25
|
+
/**
|
|
26
|
+
* Where a value sits: surface, component and property, plus the data-model path it came from.
|
|
27
|
+
* @typedef {{ surfaceId: string, componentId?: string, property: string, dataPath?: string, line?: number }} Place
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Basic-catalog properties the client fetches without a click. */
|
|
31
|
+
const FETCHED_PROPERTIES = /** @type {Record<string, string>} */ ({ Image: 'url', Video: 'url', AudioPlayer: 'url' });
|
|
32
|
+
/** The catalog: openUrl must resolve to http or https. */
|
|
33
|
+
const OPEN_URL_POLICY = { allowedSchemes: ['http', 'https'], allowRelativeUrls: true };
|
|
34
|
+
const MAX_SHOWN = 120;
|
|
35
|
+
|
|
36
|
+
/** @param {unknown} value */
|
|
37
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
38
|
+
/** @param {string} value */
|
|
39
|
+
const shown = (value) => (value.length > MAX_SHOWN ? `${value.slice(0, MAX_SHOWN)}…` : value);
|
|
40
|
+
/** @param {Place} p */
|
|
41
|
+
const placeName = (p) => `${p.surfaceId}/${p.componentId ?? 'theme'}.${p.property}${p.dataPath ? ` <- ${p.dataPath}` : ''}`;
|
|
42
|
+
/** @param {Place} p */
|
|
43
|
+
const placeText = (p) =>
|
|
44
|
+
`${p.componentId ? `component "${p.componentId}"` : 'the surface theme'} (surface "${p.surfaceId}") ${p.property}${p.dataPath ? `, bound to ${p.dataPath}` : ''}`;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Messages from the text of a file: one message, an array of them, an object with `messages` (the
|
|
48
|
+
* list wrapper and the specification's samples), or JSONL. Throws on a line that does not parse.
|
|
49
|
+
* @param {string} text
|
|
50
|
+
* @returns {A2uiEntry[]}
|
|
51
|
+
*/
|
|
52
|
+
export function parseA2uiMessages(text) {
|
|
53
|
+
/** @type {unknown} */
|
|
54
|
+
let whole;
|
|
55
|
+
try {
|
|
56
|
+
whole = JSON.parse(text);
|
|
57
|
+
} catch {
|
|
58
|
+
whole = undefined;
|
|
59
|
+
}
|
|
60
|
+
if (whole !== undefined) {
|
|
61
|
+
if (Array.isArray(whole)) return whole.map((message) => ({ message }));
|
|
62
|
+
if (isObject(whole) && Array.isArray(/** @type {any} */ (whole).messages)) return /** @type {any} */ (whole).messages.map((/** @type {unknown} */ message) => ({ message }));
|
|
63
|
+
return [{ message: whole }];
|
|
64
|
+
}
|
|
65
|
+
/** @type {A2uiEntry[]} */
|
|
66
|
+
const entries = [];
|
|
67
|
+
const lines = text.split('\n');
|
|
68
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
69
|
+
const line = (lines[i] ?? '').trim();
|
|
70
|
+
if (line === '') continue;
|
|
71
|
+
try {
|
|
72
|
+
entries.push({ message: JSON.parse(line), line: i + 1 });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw new Error(`line ${i + 1}: not a JSON message (${error instanceof Error ? error.message : String(error)})`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return entries;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* JSON Pointer segments; `/` and `` are the whole model.
|
|
82
|
+
* @param {string} path
|
|
83
|
+
*/
|
|
84
|
+
const segments = (path) => (path === '' || path === '/' ? [] : path.split('/').slice(1).map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~')));
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {unknown} model
|
|
88
|
+
* @param {string} path
|
|
89
|
+
*/
|
|
90
|
+
function readPath(model, path) {
|
|
91
|
+
/** @type {any} */
|
|
92
|
+
let node = model;
|
|
93
|
+
for (const key of segments(path)) {
|
|
94
|
+
if (node === null || typeof node !== 'object') return undefined;
|
|
95
|
+
node = node[key];
|
|
96
|
+
}
|
|
97
|
+
return node;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The model after one updateDataModel: `value` replaces what is at `path`; no `value` removes it.
|
|
102
|
+
* @param {unknown} model
|
|
103
|
+
* @param {string} path
|
|
104
|
+
* @param {unknown} value
|
|
105
|
+
* @param {boolean} remove
|
|
106
|
+
*/
|
|
107
|
+
function writePath(model, path, value, remove) {
|
|
108
|
+
const keys = segments(path);
|
|
109
|
+
if (keys.length === 0) return remove ? {} : structuredClone(value);
|
|
110
|
+
/** @type {any} */
|
|
111
|
+
const root = isObject(model) || Array.isArray(model) ? model : {};
|
|
112
|
+
let node = root;
|
|
113
|
+
for (const key of keys.slice(0, -1)) {
|
|
114
|
+
if (node[key] === null || typeof node[key] !== 'object') node[key] = {};
|
|
115
|
+
node = node[key];
|
|
116
|
+
}
|
|
117
|
+
const last = /** @type {string} */ (keys.at(-1));
|
|
118
|
+
if (remove) delete node[last];
|
|
119
|
+
else node[last] = structuredClone(value);
|
|
120
|
+
return root;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `formatString` with only `${/absolute/path}` interpolations, resolved; null when it calls a
|
|
125
|
+
* function or uses a relative path, which only the client can resolve.
|
|
126
|
+
* @param {string} template
|
|
127
|
+
* @param {unknown} model
|
|
128
|
+
*/
|
|
129
|
+
function interpolate(template, model) {
|
|
130
|
+
let resolvable = true;
|
|
131
|
+
const out = template.replace(/(\\?)\$\{([^}]*)\}/g, (match, escape, expression) => {
|
|
132
|
+
if (escape) return match.slice(1);
|
|
133
|
+
const path = String(expression).trim();
|
|
134
|
+
if (!/^\/[^\s(),:'"]*$/.test(path)) {
|
|
135
|
+
resolvable = false;
|
|
136
|
+
return '';
|
|
137
|
+
}
|
|
138
|
+
const value = readPath(model, path);
|
|
139
|
+
return value === undefined || value === null ? '' : String(value);
|
|
140
|
+
});
|
|
141
|
+
return resolvable ? out : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Interpolations that are not escaped with a backslash. */
|
|
145
|
+
const INTERPOLATION_RE = /(?<!\\)\$\{/;
|
|
146
|
+
/** Scheme and authority of an absolute URL template. */
|
|
147
|
+
const AUTHORITY_RE = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Markup the basic catalog keeps out of Text. Code spans and fences are left out first: `<b>` in
|
|
151
|
+
* code is shown as code.
|
|
152
|
+
* @param {string} text
|
|
153
|
+
*/
|
|
154
|
+
function textMarkup(text) {
|
|
155
|
+
const prose = text.replace(/```[\s\S]*?(```|$)/g, ' ').replace(/`[^`\n]*`/g, ' ');
|
|
156
|
+
/** @type {string[]} */
|
|
157
|
+
const kinds = [];
|
|
158
|
+
const TAG_RE = /<\/?[a-z][a-z0-9-]*(\s[^<>]*)?\/?>|<!--[\s\S]*?(-->|$)/gi;
|
|
159
|
+
if (TAG_RE.test(prose)) kinds.push('raw HTML');
|
|
160
|
+
// A URL inside a tag is part of the HTML, not a link of its own.
|
|
161
|
+
const withoutTags = prose.replace(TAG_RE, ' ');
|
|
162
|
+
if (/!\[[^\]]*\](\([^)]*\)|\[[^\]]*\])/.test(withoutTags)) kinds.push('an image');
|
|
163
|
+
const withoutImages = withoutTags.replace(/!\[[^\]]*\](\([^)]*\)|\[[^\]]*\])/g, ' ');
|
|
164
|
+
if (/\[[^\]]+\]\([^)]*\)|\[[^\]]+\]\[[^\]]*\]|<[a-z][a-z0-9+.-]*:[^\s>]+>|\bhttps?:\/\/\S|\bwww\.[a-z0-9-]+\.[a-z]/i.test(withoutImages)) kinds.push('a link');
|
|
165
|
+
return kinds;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Every `{ call: 'openUrl' }` inside a component, with the property path that leads to it.
|
|
170
|
+
* @param {unknown} node
|
|
171
|
+
* @param {string} at
|
|
172
|
+
* @param {Array<{ args: any, at: string }>} out
|
|
173
|
+
*/
|
|
174
|
+
function findOpenUrlCalls(node, at, out) {
|
|
175
|
+
if (Array.isArray(node)) {
|
|
176
|
+
node.forEach((item, i) => findOpenUrlCalls(item, `${at}[${i}]`, out));
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
if (!isObject(node)) return out;
|
|
180
|
+
const object = /** @type {Record<string, unknown>} */ (node);
|
|
181
|
+
if (object.call === 'openUrl') out.push({ args: object.args, at });
|
|
182
|
+
for (const [key, value] of Object.entries(object)) findOpenUrlCalls(value, at ? `${at}.${key}` : key, out);
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Findings over a stream of A2UI v0.9 messages. `uri` is the file the messages came from.
|
|
188
|
+
* @param {readonly (A2uiEntry | unknown)[]} input messages, or entries with their line
|
|
189
|
+
* @param {string} uri
|
|
190
|
+
* @returns {Finding[]}
|
|
191
|
+
*/
|
|
192
|
+
export function lintA2uiMessages(input, uri) {
|
|
193
|
+
/** @type {A2uiEntry[]} */
|
|
194
|
+
const entries = input.map((item) => (isObject(item) && 'message' in /** @type {object} */ (item) ? /** @type {A2uiEntry} */ (item) : { message: item }));
|
|
195
|
+
/** @type {Finding[]} */
|
|
196
|
+
const findings = [];
|
|
197
|
+
const reported = new Set();
|
|
198
|
+
/** @type {Set<string>} */
|
|
199
|
+
const remoteHosts = new Set();
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {string} ruleId
|
|
203
|
+
* @param {string} message
|
|
204
|
+
* @param {Place} place
|
|
205
|
+
* @param {Record<string, unknown>} [properties]
|
|
206
|
+
*/
|
|
207
|
+
const report = (ruleId, message, place, properties = {}) => {
|
|
208
|
+
const key = `${ruleId}|${placeName(place)}|${String(properties.value ?? '')}`;
|
|
209
|
+
if (reported.has(key)) return;
|
|
210
|
+
reported.add(key);
|
|
211
|
+
findings.push({
|
|
212
|
+
ruleId,
|
|
213
|
+
message,
|
|
214
|
+
uri,
|
|
215
|
+
...(place.line ? { region: { startLine: place.line } } : {}),
|
|
216
|
+
logical: { name: placeName(place), kind: 'member' },
|
|
217
|
+
properties: { surfaceId: place.surfaceId, ...(place.componentId ? { componentId: place.componentId } : {}), property: place.property, ...(place.dataPath ? { dataPath: place.dataPath } : {}), ...properties },
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* One URL value, literal or resolved.
|
|
223
|
+
* @param {unknown} raw
|
|
224
|
+
* @param {UrlUse} use
|
|
225
|
+
* @param {Place} place
|
|
226
|
+
*/
|
|
227
|
+
const checkUrlValue = (raw, use, place) => {
|
|
228
|
+
if (typeof raw !== 'string' || raw.trim() === '') return;
|
|
229
|
+
const verdict = checkUrl(raw, OPEN_URL_POLICY);
|
|
230
|
+
if (!verdict.ok) {
|
|
231
|
+
// A fetched URL with another scheme (data:, blob:) makes no network request; only navigation is the catalog's concern.
|
|
232
|
+
if (use === 'openUrl') report('A2UI001', `openUrl in ${placeText(place)} opens "${shown(raw)}": ${verdict.reason}; the basic catalog allows http and https only.`, place, { value: raw });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (verdict.parsed.relative) return;
|
|
236
|
+
const { url } = verdict.parsed;
|
|
237
|
+
const sink = sinkScope(parseDomainPattern(url.host));
|
|
238
|
+
if (sink?.scope === 'shared') {
|
|
239
|
+
report('A2UI002', `${placeText(place)} ${use === 'openUrl' ? 'navigates to' : 'loads'} ${url.host}, which is on the sink denylist (${sink.entry.category}: ${sink.entry.pattern}).`, place, { value: raw, host: url.host });
|
|
240
|
+
}
|
|
241
|
+
if (use === 'fetched') {
|
|
242
|
+
remoteHosts.add(url.host);
|
|
243
|
+
const problem = checkUrlHeuristics(url, DEFAULT_URL_HEURISTICS);
|
|
244
|
+
if (problem) report('A2UI003', `${placeText(place)} is fetched without a click and ${problem.reason.replace(/^URL /, '')}: "${shown(raw)}".`, place, { value: raw, code: problem.code });
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* @param {unknown} raw
|
|
250
|
+
* @param {Place} place
|
|
251
|
+
*/
|
|
252
|
+
const checkTextValue = (raw, place) => {
|
|
253
|
+
if (typeof raw !== 'string') return;
|
|
254
|
+
const kinds = textMarkup(raw);
|
|
255
|
+
if (kinds.length > 0) report('A2UI006', `Text in ${placeText(place)} contains ${kinds.join(', ')}; the basic catalog allows Markdown without HTML, images or links.`, place, { value: raw, kinds });
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @param {string} template
|
|
260
|
+
* @param {UrlUse} use
|
|
261
|
+
* @param {Place} place
|
|
262
|
+
*/
|
|
263
|
+
const checkTemplate = (template, use, place) => {
|
|
264
|
+
const at = template.search(INTERPOLATION_RE);
|
|
265
|
+
if (at === -1) return;
|
|
266
|
+
const authority = AUTHORITY_RE.exec(template)?.[0] ?? '';
|
|
267
|
+
if (at < authority.length || authority === '') {
|
|
268
|
+
report('A2UI004', `formatString in ${placeText(place)} takes the ${authority === '' ? 'scheme and host' : 'host'} from data: "${shown(template)}". No host allowlist can be checked before render.`, place, { value: template });
|
|
269
|
+
} else if (use === 'fetched') {
|
|
270
|
+
report('A2UI005', `formatString in ${placeText(place)} puts data into a URL fetched without a click: "${shown(template)}". The client must check the resolved URL.`, place, { value: template });
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Bindings per surface, resolved against the model after every message.
|
|
276
|
+
* @type {Map<string, { model: unknown, bindings: Array<{ kind: 'path' | 'template', source: string, check: 'url' | 'text', use: UrlUse, place: Place }> }>}
|
|
277
|
+
*/
|
|
278
|
+
const surfaces = new Map();
|
|
279
|
+
/** @param {string} id */
|
|
280
|
+
const surface = (id) => {
|
|
281
|
+
let s = surfaces.get(id);
|
|
282
|
+
if (!s) {
|
|
283
|
+
s = { model: {}, bindings: [] };
|
|
284
|
+
surfaces.set(id, s);
|
|
285
|
+
}
|
|
286
|
+
return s;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* A property value: a literal is checked now, a binding or template is remembered.
|
|
291
|
+
* @param {unknown} value
|
|
292
|
+
* @param {'url' | 'text'} check
|
|
293
|
+
* @param {UrlUse} use
|
|
294
|
+
* @param {Place} place
|
|
295
|
+
*/
|
|
296
|
+
const property = (value, check, use, place) => {
|
|
297
|
+
if (typeof value === 'string') {
|
|
298
|
+
if (check === 'url') checkUrlValue(value, use, place);
|
|
299
|
+
else checkTextValue(value, place);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (!isObject(value)) return;
|
|
303
|
+
const v = /** @type {any} */ (value);
|
|
304
|
+
const bindings = surface(place.surfaceId).bindings;
|
|
305
|
+
if (typeof v.path === 'string' && v.path.startsWith('/')) bindings.push({ kind: 'path', source: v.path, check, use, place });
|
|
306
|
+
else if (v.call === 'formatString' && typeof v.args?.value === 'string') {
|
|
307
|
+
if (check === 'url') checkTemplate(v.args.value, use, place);
|
|
308
|
+
bindings.push({ kind: 'template', source: v.args.value, check, use, place });
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/** @param {string} id */
|
|
313
|
+
const resolveBindings = (id, line = /** @type {number | undefined} */ (undefined)) => {
|
|
314
|
+
const s = surface(id);
|
|
315
|
+
for (const b of s.bindings) {
|
|
316
|
+
const value = b.kind === 'path' ? readPath(s.model, b.source) : interpolate(b.source, s.model);
|
|
317
|
+
if (value === undefined || value === null) continue;
|
|
318
|
+
const place = { ...b.place, dataPath: b.kind === 'path' ? b.source : `formatString "${shown(b.source)}"`, line: line ?? b.place.line };
|
|
319
|
+
if (b.check === 'url') checkUrlValue(value, b.use, place);
|
|
320
|
+
else checkTextValue(value, place);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
for (const { message, line } of entries) {
|
|
325
|
+
if (!isObject(message)) continue;
|
|
326
|
+
const m = /** @type {any} */ (message);
|
|
327
|
+
if (isObject(m.createSurface)) {
|
|
328
|
+
const id = String(m.createSurface.surfaceId ?? '?');
|
|
329
|
+
surface(id);
|
|
330
|
+
if (m.createSurface.theme?.iconUrl !== undefined) checkUrlValue(m.createSurface.theme.iconUrl, 'fetched', { surfaceId: id, property: 'theme.iconUrl', line });
|
|
331
|
+
} else if (isObject(m.updateComponents)) {
|
|
332
|
+
const id = String(m.updateComponents.surfaceId ?? '?');
|
|
333
|
+
for (const c of Array.isArray(m.updateComponents.components) ? m.updateComponents.components : []) {
|
|
334
|
+
if (!isObject(c)) continue;
|
|
335
|
+
const componentId = String(c.id ?? '?');
|
|
336
|
+
const fetched = FETCHED_PROPERTIES[String(c.component)];
|
|
337
|
+
if (fetched) property(c[fetched], 'url', 'fetched', { surfaceId: id, componentId, property: fetched, line });
|
|
338
|
+
if (c.component === 'Text') property(c.text, 'text', 'fetched', { surfaceId: id, componentId, property: 'text', line });
|
|
339
|
+
for (const call of findOpenUrlCalls(c, '', [])) {
|
|
340
|
+
property(call.args?.url, 'url', 'openUrl', { surfaceId: id, componentId, property: `${call.at}.args.url`, line });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
resolveBindings(id);
|
|
344
|
+
} else if (isObject(m.updateDataModel)) {
|
|
345
|
+
const id = String(m.updateDataModel.surfaceId ?? '?');
|
|
346
|
+
const s = surface(id);
|
|
347
|
+
const update = m.updateDataModel;
|
|
348
|
+
s.model = writePath(s.model, typeof update.path === 'string' ? update.path : '/', update.value, !('value' in update));
|
|
349
|
+
resolveBindings(id, line);
|
|
350
|
+
} else if (isObject(m.deleteSurface)) {
|
|
351
|
+
surfaces.delete(String(m.deleteSurface.surfaceId ?? '?'));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (remoteHosts.size > 0) {
|
|
356
|
+
const hosts = [...remoteHosts].sort();
|
|
357
|
+
findings.push({ ruleId: 'A2UI007', message: `Surfaces load media from ${hosts.join(', ')}; a client policy must allow these hosts (for example images.hosts in @render-policy/a2ui).`, uri, properties: { hosts } });
|
|
358
|
+
}
|
|
359
|
+
return findings;
|
|
360
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
* mcp-app-lint --read read.json [--list list.json] [--tools tools.json]
|
|
7
7
|
* # results of resources/read, resources/list, tools/list
|
|
8
8
|
* mcp-app-lint --html app.html # one UI document
|
|
9
|
+
* mcp-app-lint --a2ui stream.jsonl # A2UI v0.9 messages (JSON, JSON array, { messages } or JSONL)
|
|
9
10
|
* Options: --out file.sarif, --format sarif|text (default sarif), --fail-on error|warning|note|none (default error)
|
|
10
11
|
*/
|
|
11
12
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
12
13
|
import os from 'node:os';
|
|
13
14
|
import path from 'node:path';
|
|
15
|
+
import { lintA2uiMessages, parseA2uiMessages } from './a2ui.mjs';
|
|
14
16
|
import { UI_MIME } from './analyze.mjs';
|
|
15
17
|
import { lintHtml, lintPackageScan, lintResource, lintTools, levelOf } from './lint.mjs';
|
|
16
18
|
import { packageMeta, scanPackage, scanPackageDir } from './npm.mjs';
|
|
@@ -30,6 +32,7 @@ import { toSarif, toText } from './sarif.mjs';
|
|
|
30
32
|
* @property {string} [list]
|
|
31
33
|
* @property {string} [tools]
|
|
32
34
|
* @property {string} [html]
|
|
35
|
+
* @property {string} [a2ui]
|
|
33
36
|
* @property {string} [out]
|
|
34
37
|
* @property {boolean} [help]
|
|
35
38
|
*/
|
|
@@ -54,6 +57,7 @@ function parseArgs(argv) {
|
|
|
54
57
|
else if (arg === '--list') options.list = next();
|
|
55
58
|
else if (arg === '--tools') options.tools = next();
|
|
56
59
|
else if (arg === '--html') options.html = next();
|
|
60
|
+
else if (arg === '--a2ui') options.a2ui = next();
|
|
57
61
|
else if (arg === '--out') options.out = next();
|
|
58
62
|
else if (arg === '--format') options.format = next();
|
|
59
63
|
else if (arg === '--fail-on') options.failOn = next();
|
|
@@ -87,8 +91,8 @@ function relativeUri(uri) {
|
|
|
87
91
|
*/
|
|
88
92
|
export async function run(argv, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
89
93
|
const options = parseArgs(argv);
|
|
90
|
-
if (options.help || (!options.dir && !options.package && !options.read && !options.html && !options.tools)) {
|
|
91
|
-
stderr.write('usage: mcp-app-lint (--dir <path> | --package <name> | --read <read.json> [--list <list.json>] | --html <file>) [--tools <tools.json>] [--out file] [--format sarif|text] [--fail-on error|warning|note|none]\n');
|
|
94
|
+
if (options.help || (!options.dir && !options.package && !options.read && !options.html && !options.tools && !options.a2ui)) {
|
|
95
|
+
stderr.write('usage: mcp-app-lint (--dir <path> | --package <name> | --read <read.json> [--list <list.json>] | --html <file> | --a2ui <messages>) [--tools <tools.json>] [--out file] [--format sarif|text] [--fail-on error|warning|note|none]\n');
|
|
92
96
|
return 2;
|
|
93
97
|
}
|
|
94
98
|
/** @type {Finding[]} */
|
|
@@ -123,6 +127,9 @@ export async function run(argv, { stdout = process.stdout, stderr = process.stde
|
|
|
123
127
|
if (options.html) {
|
|
124
128
|
findings.push(...lintHtml(await readFile(options.html, 'utf8'), options.html));
|
|
125
129
|
}
|
|
130
|
+
if (options.a2ui) {
|
|
131
|
+
findings.push(...lintA2uiMessages(parseA2uiMessages(await readFile(options.a2ui, 'utf8')), options.a2ui));
|
|
132
|
+
}
|
|
126
133
|
if (options.tools) {
|
|
127
134
|
const tools = await readJson(options.tools);
|
|
128
135
|
findings.push(...lintTools(Array.isArray(tools?.tools) ? tools.tools : tools, options.tools));
|
package/src/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { lintA2uiMessages, parseA2uiMessages } from './a2ui.mjs';
|
|
1
2
|
export { analyzeHtml, analyzeTools, analyzeUiMeta, compareListRead, ESCAPE_FUNCTIONS, isHandwritten, postMessageStarCalls, UI_MIME, uiMetaOf, withSinkHosts } from './analyze.mjs';
|
|
2
3
|
export { categorizeDomain, categorizeHost, classifyDomains, DOMAIN_CATEGORIES, parseDomainPattern, sinkFor, sinkScope } from './domains.mjs';
|
|
3
4
|
export { levelOf, lintHtml, lintPackageScan, lintResource, lintTools, lintUiMeta } from './lint.mjs';
|
|
@@ -7,6 +8,7 @@ export { toSarif, toText, TOOL_VERSION } from './sarif.mjs';
|
|
|
7
8
|
export { run } from './cli.mjs';
|
|
8
9
|
|
|
9
10
|
/**
|
|
11
|
+
* @typedef {import('./a2ui.mjs').A2uiEntry} A2uiEntry
|
|
10
12
|
* @typedef {import('./analyze.mjs').UiMeta} UiMeta
|
|
11
13
|
* @typedef {import('./analyze.mjs').CspKey} CspKey
|
|
12
14
|
* @typedef {import('./analyze.mjs').HtmlAnalysis} HtmlAnalysis
|
package/src/rules.mjs
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/** @typedef {'error' | 'warning' | 'note'} Level */
|
|
2
2
|
/**
|
|
3
|
-
* A rule in SARIF terms: `short` and `full` become its short and full description
|
|
4
|
-
*
|
|
3
|
+
* A rule in SARIF terms: `short` and `full` become its short and full description; `tags` default to
|
|
4
|
+
* security and mcp-apps.
|
|
5
|
+
* @typedef {{ id: string, name: string, level: Level, short: string, full: string, tags?: readonly string[] }} Rule
|
|
5
6
|
*/
|
|
6
7
|
|
|
8
|
+
const A2UI_TAGS = ['security', 'a2ui'];
|
|
9
|
+
|
|
7
10
|
/**
|
|
8
11
|
* The rules, in SARIF terms. Ids are stable; names are what a scanner shows.
|
|
9
12
|
* @type {readonly Rule[]}
|
|
@@ -28,6 +31,13 @@ export const RULES = [
|
|
|
28
31
|
{ id: 'MCPAPP017', name: 'meta-list-read-mismatch', level: 'warning', short: 'resources/read declares a different policy than resources/list', full: 'Hosts must prefer the read-time policy. Reported when both sides declare the same field (csp, permissions or domain) with different values and the read-time CSP adds nothing (that is MCPAPP018). A listing that declares no CSP is not a mismatch: the MCP Apps SDK puts the CSP in the read result only.' },
|
|
29
32
|
{ id: 'MCPAPP018', name: 'meta-read-wider-than-list', level: 'error', short: 'resources/read widens the policy seen at resources/list', full: 'Both sides declare a CSP and the read-time one has an entry the listing does not have under the same key (a new domain, a replaced one, or a wildcard). This is the pattern a host that reviews at connection time cannot see.' },
|
|
30
33
|
{ id: 'MCPAPP019', name: 'csp-sink-tenant-host', level: 'note', short: 'CSP entry names one account on a multi-tenant service', full: 'The host is a single bucket, worker or app on a storage or serverless service from the sink denylist (acct.blob.core.windows.net, pub-<id>.r2.dev). It reaches only that account, which is fine while the owner keeps it; a deleted bucket or app name can be registered by someone else.' },
|
|
34
|
+
{ id: 'A2UI001', name: 'a2ui-openurl-scheme', level: 'error', tags: A2UI_TAGS, short: 'openUrl target is not http or https', full: "The basic catalog requires openUrl to resolve to http: or https: and to abort otherwise. javascript:, data: and other schemes are script or content the agent chose; a client that opens them runs it. Checked on literals and on values bound from the data model in the same stream." },
|
|
35
|
+
{ id: 'A2UI002', name: 'a2ui-sink-host', level: 'error', tags: A2UI_TAGS, short: 'A URL in the surface points at a data sink', full: 'An Image, Video or AudioPlayer url, the theme icon, or an openUrl target names a host on the sink denylist (request catchers, tunnels, anonymous serverless endpoints, form builders, public object storage) where anyone can open an account. Media is fetched as soon as it renders, without a click.' },
|
|
36
|
+
{ id: 'A2UI003', name: 'a2ui-url-encoded-payload', level: 'warning', tags: A2UI_TAGS, short: 'A URL fetched without a click carries an encoded payload', full: 'A media or icon URL is longer than 2048 characters or has a long high-entropy path segment or query value, the shape of data encoded into a request. The client fetches it as soon as it renders. Checked on literals, bound values and formatString results the stream lets the scanner resolve.' },
|
|
37
|
+
{ id: 'A2UI004', name: 'a2ui-url-template-host', level: 'warning', tags: A2UI_TAGS, short: 'formatString takes a URL host from data', full: 'A formatString builds a media URL or openUrl target whose scheme or host comes from the data model, so the host is decided at render time and no allowlist can be reviewed before. Fix the host in the template, or check the resolved URL on the client (@render-policy/a2ui).' },
|
|
38
|
+
{ id: 'A2UI005', name: 'a2ui-url-template-data', level: 'note', tags: A2UI_TAGS, short: 'formatString puts data into a URL fetched without a click', full: 'A media URL is assembled from data model values. Whatever the data holds reaches the host in the request as soon as the component renders. The client should check the resolved URL (length, entropy, host), which is what @render-policy/a2ui does.' },
|
|
39
|
+
{ id: 'A2UI006', name: 'a2ui-text-markup', level: 'warning', tags: A2UI_TAGS, short: 'Text contains HTML, an image or a link', full: 'The basic catalog allows simple Markdown in Text "without HTML, images, or links". The contract binds the agent, not the renderer: a client that renders Text with a general Markdown renderer fetches the image or shows the link. Code spans and fences are not reported. Bare URLs count, because GFM renderers turn them into links.' },
|
|
40
|
+
{ id: 'A2UI007', name: 'a2ui-remote-media-hosts', level: 'note', tags: A2UI_TAGS, short: 'Surfaces load media from remote hosts', full: 'The hosts that media and icon URLs in the stream point at, literal or resolved. A client policy has to allow them, or the images stay blocked.' },
|
|
31
41
|
];
|
|
32
42
|
|
|
33
43
|
export const RULE_INDEX = new Map(RULES.map((rule, index) => [rule.id, index]));
|
package/src/sarif.mjs
CHANGED
|
@@ -50,7 +50,7 @@ export function toSarif(findings, { invocation = {} } = {}) {
|
|
|
50
50
|
fullDescription: { text: rule.full },
|
|
51
51
|
help: { text: rule.full },
|
|
52
52
|
defaultConfiguration: { level: rule.level },
|
|
53
|
-
properties: { tags: ['security', 'mcp-apps'] },
|
|
53
|
+
properties: { tags: rule.tags ?? ['security', 'mcp-apps'] },
|
|
54
54
|
})),
|
|
55
55
|
},
|
|
56
56
|
},
|