@usegraft/mdx-safety 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anderson Joseph
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # @usegraft/mdx-safety
2
+
3
+ > Refuse executable MDX before it is stored or rendered.
4
+
5
+ Part of [Graft](https://github.com/AndersonDesign1/graft), a CMS built so an AI agent is the primary operator.
6
+
7
+ ## Why
8
+
9
+ MDX is code. `{expr}` evaluates JavaScript, `import` pulls modules in, and `@mdx-js/mdx`'s `run()` evaluates the compiled body with `new Function` in the host runtime, with full `process`, `fetch` and dynamic `import()`.
10
+
11
+ For content an operator wrote and reviewed in git, that is the feature. It stops being the feature the moment someone else can author a page: on shared infrastructure, one author's body reaches every other tenant.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm i @usegraft/mdx-safety
17
+ ```
18
+
19
+ ## Use
20
+
21
+ ```ts
22
+ import { assertSafeMdx, findExecutableMdx, type MdxTrust } from "@usegraft/mdx-safety";
23
+
24
+ // throw, with every offender named at once
25
+ assertSafeMdx(body, { label: "pages/home" });
26
+
27
+ // or collect them yourself
28
+ const found = findExecutableMdx(body);
29
+ ```
30
+
31
+ Prose, GFM and components with literal attributes are unaffected. Expressions, `import`, `export`, expression-valued attributes and `{...spread}` attributes are refused.
32
+
33
+ Source the checker cannot parse throws `UncheckableMdxError` rather than passing. That is deliberate: the renderer's parser is not this one, and the gap between two independently configured parsers is exactly where executable source would hide.
34
+
35
+ ## Not a sanitiser
36
+
37
+ This refuses _executable_ constructs. It is not a general HTML sanitiser. If you render content from people you do not trust at all, put a sanitiser in front of it as well.
38
+
39
+ ---
40
+
41
+ MIT. [Repository](https://github.com/AndersonDesign1/graft) · [Changelog](https://github.com/AndersonDesign1/graft/blob/feat/core/packages/mdx-safety/CHANGELOG.md) · [Security policy](https://github.com/AndersonDesign1/graft/blob/feat/core/SECURITY.md)
@@ -0,0 +1,59 @@
1
+ /** One executable construct found in a body, with where it was. */
2
+ interface ExecutableNode {
3
+ /** What it is, in the reader's terms rather than the AST's. */
4
+ kind: "expression" | "import-or-export" | "attribute-expression" | "attribute-spread" | "scripting-element" | "event-handler";
5
+ /** 1-based line, when the parser knew it. */
6
+ line?: number;
7
+ /** The offending source, truncated. */
8
+ snippet?: string;
9
+ }
10
+ /** Thrown when the checker cannot parse a body, so it cannot vouch for it. */
11
+ declare class UncheckableMdxError extends Error {
12
+ readonly cause: unknown;
13
+ constructor(cause: unknown);
14
+ }
15
+ /**
16
+ * Every executable construct in an MDX body, in source order.
17
+ *
18
+ * Returns them rather than throwing so a caller can report all of them at once
19
+ * — an author fixing one at a time learns the rule slowly and resents it.
20
+ *
21
+ * Source the checker cannot parse throws {@link UncheckableMdxError}. It used
22
+ * to return `[]` on the reasoning that "a body that will not parse cannot
23
+ * execute either" — which only holds if this parser understands at least as
24
+ * much as the one that renders. Two independently-configured parsers WILL
25
+ * drift, and the failure mode of that shortcut is to wave through exactly the
26
+ * source that sits in the gap. Refusing what we cannot read is the only
27
+ * direction that stays safe when they diverge.
28
+ */
29
+ declare function findExecutableMdx(source: string): ExecutableNode[];
30
+ /**
31
+ * How much of MDX a body is allowed to be.
32
+ *
33
+ * `"restricted"` refuses executable constructs. `"full"` accepts them, and is
34
+ * only correct where every author has commit access, because rendering
35
+ * evaluates `{…}` and `import` as JavaScript on the server. The name is
36
+ * declared here so the compiler, the SDKs and `graft.config.ts` all mean the
37
+ * same thing by it.
38
+ */
39
+ type MdxTrust = "restricted" | "full";
40
+ interface AssertSafeMdxOptions {
41
+ /** What is being checked, for the error message (e.g. "pages/home"). */
42
+ label?: string;
43
+ }
44
+ /**
45
+ * Throw unless the body is free of executable constructs.
46
+ *
47
+ * Called on the surfaces that accept content from someone who is not the
48
+ * operator — MCP `write_content`, Studio document saves.
49
+ *
50
+ * Content already in git is checked too, by `graft compile` via
51
+ * {@link findExecutableMdx}, unless the project sets `mdxTrust = "full"`. That
52
+ * setting is what "code review is the control" looks like when a project
53
+ * actually claims it. Compile checks because `MdxBody` refuses executable
54
+ * bodies at render by default, so an unchecked tree fails per-request in
55
+ * production instead of at build time.
56
+ */
57
+ declare function assertSafeMdx(source: string, options?: AssertSafeMdxOptions): void;
58
+
59
+ export { type AssertSafeMdxOptions, type ExecutableNode, type MdxTrust, UncheckableMdxError, assertSafeMdx, findExecutableMdx };
package/dist/index.js ADDED
@@ -0,0 +1,114 @@
1
+ // src/index.ts
2
+ import { GraftError } from "@usegraft/contracts";
3
+ import remarkGfm from "remark-gfm";
4
+ import remarkMdx from "remark-mdx";
5
+ import remarkParse from "remark-parse";
6
+ import { unified } from "unified";
7
+ import { visit } from "unist-util-visit";
8
+ var KIND_LABEL = {
9
+ expression: "a `{\u2026}` expression",
10
+ "import-or-export": "an `import` or `export`",
11
+ "attribute-expression": "an attribute whose value is a `{\u2026}` expression",
12
+ "attribute-spread": "a `{...spread}` attribute",
13
+ "scripting-element": "an element that can load or run code",
14
+ "event-handler": "an inline event-handler attribute"
15
+ };
16
+ var SCRIPTING_ELEMENTS = /* @__PURE__ */ new Set([
17
+ "script",
18
+ "iframe",
19
+ "object",
20
+ "embed",
21
+ "frame",
22
+ "frameset",
23
+ "base"
24
+ ]);
25
+ var EVENT_HANDLER_RE = /^on[a-z]/i;
26
+ function snippetOf(value) {
27
+ if (typeof value !== "string" || value.length === 0) return void 0;
28
+ const flat = value.replace(/\s+/g, " ").trim();
29
+ return flat.length > 80 ? `${flat.slice(0, 77)}\u2026` : flat;
30
+ }
31
+ var processor = unified().use(remarkParse).use(remarkGfm).use(remarkMdx);
32
+ var UncheckableMdxError = class extends Error {
33
+ constructor(cause) {
34
+ super("MDX could not be parsed for safety checking");
35
+ this.cause = cause;
36
+ this.name = "UncheckableMdxError";
37
+ }
38
+ cause;
39
+ };
40
+ function findExecutableMdx(source) {
41
+ let tree;
42
+ try {
43
+ tree = processor.parse(source);
44
+ } catch (error) {
45
+ throw new UncheckableMdxError(error);
46
+ }
47
+ const found = [];
48
+ const at = (node) => node.position?.start?.line;
49
+ visit(tree, (node) => {
50
+ if (node.type === "mdxFlowExpression" || node.type === "mdxTextExpression") {
51
+ found.push({ kind: "expression", line: at(node), snippet: snippetOf(node.value) });
52
+ return;
53
+ }
54
+ if (node.type === "mdxjsEsm") {
55
+ found.push({ kind: "import-or-export", line: at(node), snippet: snippetOf(node.value) });
56
+ return;
57
+ }
58
+ if (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") {
59
+ const tag = node.name;
60
+ if (typeof tag === "string" && SCRIPTING_ELEMENTS.has(tag.toLowerCase())) {
61
+ found.push({ kind: "scripting-element", line: at(node), snippet: `<${tag}>` });
62
+ }
63
+ for (const attribute of node.attributes ?? []) {
64
+ if (attribute.type === "mdxJsxExpressionAttribute") {
65
+ found.push({
66
+ kind: "attribute-spread",
67
+ line: at(node),
68
+ snippet: snippetOf(attribute.value)
69
+ });
70
+ continue;
71
+ }
72
+ if (typeof attribute.name === "string" && EVENT_HANDLER_RE.test(attribute.name)) {
73
+ found.push({
74
+ kind: "event-handler",
75
+ line: at(node),
76
+ snippet: snippetOf(attribute.name)
77
+ });
78
+ continue;
79
+ }
80
+ const value = attribute.value;
81
+ if (value !== null && typeof value === "object" && value.type === "mdxJsxAttributeValueExpression") {
82
+ found.push({
83
+ kind: "attribute-expression",
84
+ line: at(node),
85
+ snippet: snippetOf(attribute.name)
86
+ });
87
+ }
88
+ }
89
+ }
90
+ });
91
+ return found;
92
+ }
93
+ function assertSafeMdx(source, options = {}) {
94
+ const found = findExecutableMdx(source);
95
+ if (found.length === 0) return;
96
+ const where = options.label ? ` in ${options.label}` : "";
97
+ const listed = found.slice(0, 5).map((node) => {
98
+ const line = node.line === void 0 ? "" : ` (line ${node.line})`;
99
+ const snippet = node.snippet === void 0 ? "" : `: ${node.snippet}`;
100
+ return `${KIND_LABEL[node.kind]}${line}${snippet}`;
101
+ }).join("; ");
102
+ const more = found.length > 5 ? ` \u2026and ${found.length - 5} more` : "";
103
+ throw new GraftError({
104
+ code: "INPUT_VALIDATION_FAILED",
105
+ message: `Executable MDX is not accepted${where} \u2014 found ${listed}${more}.`,
106
+ fix: "Write prose, Markdown and components with literal attributes. `{\u2026}` expressions, `import`, `export` and spread attributes are refused because rendering evaluates them as JavaScript on the server. If this content is operator-authored and needs full MDX, commit it to the repository instead, where code review is the control.",
107
+ details: { found: found.length, nodes: found.slice(0, 20) }
108
+ });
109
+ }
110
+ export {
111
+ UncheckableMdxError,
112
+ assertSafeMdx,
113
+ findExecutableMdx
114
+ };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@usegraft/mdx-safety",
3
+ "version": "0.2.0",
4
+ "description": "Refuse executable MDX before it is stored or rendered. MDX is code, and rendering evaluates it on the server.",
5
+ "keywords": [
6
+ "agent",
7
+ "ai",
8
+ "cms",
9
+ "content-security",
10
+ "graft",
11
+ "headless-cms",
12
+ "mcp",
13
+ "mdx",
14
+ "sandbox",
15
+ "security",
16
+ "typescript"
17
+ ],
18
+ "homepage": "https://github.com/AndersonDesign1/graft#readme",
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/AndersonDesign1/graft.git",
23
+ "directory": "packages/mdx-safety"
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "type": "module",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ }
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "dependencies": {
39
+ "@usegraft/contracts": "0.2.0",
40
+ "remark-gfm": "^4.0.1",
41
+ "remark-mdx": "^3.1.1",
42
+ "remark-parse": "^11.0.0",
43
+ "unified": "^11.0.5",
44
+ "unist-util-visit": "^5.1.0"
45
+ },
46
+ "engines": {
47
+ "node": ">=22.16"
48
+ },
49
+ "scripts": {
50
+ "build": "tsup src/index.ts --format esm --dts --clean",
51
+ "dev": "tsup src/index.ts --format esm --watch",
52
+ "typecheck": "tsc --noEmit",
53
+ "test": "vitest run --passWithNoTests"
54
+ }
55
+ }