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 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
 
@@ -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
@@ -8,6 +8,7 @@ export type CliOptions = {
8
8
  list?: string;
9
9
  tools?: string;
10
10
  html?: string;
11
+ a2ui?: string;
11
12
  out?: string;
12
13
  help?: boolean;
13
14
  };
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.mts","sourceRoot":"","sources":["../src/cli.mjs"],"names":[],"mappings":"AAmBG,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,GAAG,AAAvB,CACA,EADW,MAAM,CACjB;IAAqB,IAAI,AAAzB,CAA0B,EAAf,OAAO,CAAQ;CAAA,CAAA;AA+C7B;;;;;;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,CAwD3B"}
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
@@ -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;AAjB/C;;;;;;;;;;;;;;;;;;GAkBG"}
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
- * @typedef {{ id: string, name: string, level: Level, short: string, full: string }} Rule
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.
@@ -1 +1 @@
1
- {"version":3,"file":"rules.d.mts","sourceRoot":"","sources":["../src/rules.mjs"],"names":[],"mappings":"AAAA,oDAAoD;AACpD;;;GAGG;AAJC,YAAwC,KAAK,GAAnC,OAAO,GAAG,SAAS,GAAG,MAAM,CAAO;AAG9C,YAAmF,IAAI,GAA7E;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAM;AAG1F;;;GAGG;AACH,eAAO,MAAM,KAAK,EAFR,SAAS,IAAI,EAsBtB,CAAC;AAEF,eAAO,MAAM,UAAU,qBAAwD,CAAC;AAChF,2BAA2B;AAC3B,eAAO,MAAM,UAAU,SADX,MAAM,qBACoD,CAAC"}
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
@@ -38,7 +38,7 @@ export declare function toSarif(findings: readonly Finding[], { invocation }?: {
38
38
  level: import("./rules.mjs").Level;
39
39
  };
40
40
  properties: {
41
- tags: string[];
41
+ tags: readonly string[];
42
42
  };
43
43
  }[];
44
44
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mcp-app-lint",
3
- "version": "0.1.5",
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
- * @typedef {{ id: string, name: string, level: Level, short: string, full: string }} Rule
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
  },