n8n-mcp-community 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,59 @@ All notable changes to this project will be documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.2] - Frozen release candidate
10
+
11
+ Version 0.1.2 is a frozen release candidate. Availability is established only
12
+ by matching npm provenance, GitHub Release assets and signatures, and MCP
13
+ Registry readbacks; this immutable entry is not rewritten after publication.
14
+
15
+ ### Security
16
+
17
+ - The transitive `fast-uri` dependency is updated past
18
+ GHSA-v2hh-gcrm-f6hx, and `@hono/node-server` is overridden to the patched
19
+ 2.x line for GHSA-frvp-7c67-39w9. The affected HTTP adapter is unused by
20
+ this stdio-only server. The project and bundled MCPB trees audit clean, but
21
+ npm does not inherit dependency-package overrides: a disposable downstream
22
+ consumer resolves the reviewed 1.19.15 backport through MCP SDK 1.29.0.
23
+ The gate accepts only the exact known advisory or a fully clean advisory
24
+ readback while registry metadata converges, and closure still requires a clean
25
+ no-override audit of the published 0.1.2 consumer.
26
+
27
+ ### Changed
28
+
29
+ - All 44 MCP-visible tool definitions now state their specific purpose, safe
30
+ usage boundary, named sibling alternatives, behavior, and return semantics.
31
+ - Every top-level input and output field now publishes a meaningful JSON Schema
32
+ description, including the dedicated `n8n_introspect` result contract.
33
+ - Every generic tool now publishes its own required `data` result contract, and
34
+ an exact normalized `tools/list` metadata fingerprint prevents semantic drift.
35
+ - Workflow create/update now express the two-number node position as a bounded
36
+ homogeneous JSON Schema array. Runtime validation is unchanged, while Codex
37
+ can expose both tools instead of rejecting the tuple-form `items` array.
38
+ - Fork pull requests use a complete keyless contributor gate. Only trusted
39
+ maintainer pushes can authenticate and approve an exact artifact baseline.
40
+ - Installation documentation no longer treats a source-tree version as proof
41
+ that npm, GitHub, signatures, provenance, or Registry publication occurred.
42
+ - Private Glama review captures are excluded from the public Git boundary.
43
+
44
+ ## [0.1.1] - 2026-07-21
45
+
46
+ ### Security
47
+
48
+ - npm publication is now exclusively GitHub OIDC trusted publishing: the
49
+ v0.1.0 first-publish bootstrap token binding was removed from the release
50
+ workflow, and the release policy fails closed on any npm token reference.
51
+
52
+ ### Changed
53
+
54
+ - Documentation now describes the published state: the README leads with the
55
+ pinned exact-version `npx` quick start, states up front that the safety
56
+ modes are progressive rather than restrictive, and the installation,
57
+ client, compatibility, and FAQ guides replace pre-release "planned"
58
+ wording with the released routes.
59
+ - Release-asset links use the stable latest-release URL so documentation
60
+ links remain valid across versions.
61
+
9
62
  ## [0.1.0] - 2026-07-20
10
63
 
11
64
  ### Added
@@ -112,5 +165,7 @@ All notable changes to this project will be documented here. The format follows
112
165
  credentials, keys, and generated release artifacts cannot survive in that
113
166
  publishable history; authenticated public release receipts remain supported.
114
167
 
115
- [Unreleased]: https://github.com/drzamarian/n8n-mcp-community/compare/v0.1.0...HEAD
168
+ [Unreleased]: #unreleased
169
+ [0.1.2]: #012---frozen-release-candidate
170
+ [0.1.1]: https://github.com/drzamarian/n8n-mcp-community/releases/tag/v0.1.1
116
171
  [0.1.0]: https://github.com/drzamarian/n8n-mcp-community/releases/tag/v0.1.0
package/CONTRIBUTING.md CHANGED
@@ -21,14 +21,22 @@ Node.js 22 and 24 are the supported development lines.
21
21
 
22
22
  ```bash
23
23
  npm ci
24
- npm run check
24
+ npm run verify:contributor
25
25
  ```
26
26
 
27
- The complete gate covers formatting, dependency licenses and notices, strict
28
- TypeScript, the full automated test suite, a real stdio inventory check, and the
29
- production build.
27
+ The keyless contributor gate covers formatting, dependency licenses and notices,
28
+ strict TypeScript, the full automated test suite, real stdio inventories, package
29
+ and MCPB construction, a disposable downstream-consumer audit, and the production
30
+ build. It deliberately does not authenticate or approve the maintainer's exact
31
+ artifact baseline.
30
32
  Run `npm run sbom > sbom.cdx.json` when reviewing dependency changes.
31
33
 
34
+ Pull requests from forks run only this keyless gate; GitHub does not expose the
35
+ operator-held artifact-approval secret to fork workflows. After review, the
36
+ maintainer regenerates the exact baseline, approves it interactively, and runs
37
+ `npm run verify`. Release and publication workflows accept only that keyed path;
38
+ the contributor gate cannot authorize an artifact, tag, or release.
39
+
32
40
  ## Branch and commit conventions
33
41
 
34
42
  - Create a focused branch from `dev`: `feat/<topic>`, `fix/<topic>`,
package/README.md CHANGED
@@ -2,20 +2,27 @@
2
2
 
3
3
  **A security-focused Model Context Protocol server for self-hosted n8n Community Edition.**
4
4
 
5
- Connect an MCP client to n8n through 44 carefully bounded tools for workflows,
6
- executions, credentials, tags, users, diagnostics, and instance metadata. The
7
- server starts offline, defaults to read-only, uses the supported n8n Public API,
8
- and never sends workflow data to an external AI provider.
9
-
10
- > **v0.1.0:** install the exact published version or use a source checkout. See
11
- > [Installation](docs/installation.md) for the pinned npm and signed MCPB
12
- > options.
5
+ Connect an MCP client to a **bounded 44-tool n8n Community Edition management surface**: create, edit,
6
+ activate, and delete workflows, update individual nodes surgically, and manage
7
+ executions, credentials, tags, users, diagnostics, and instance metadata — 44
8
+ carefully bounded tools in total. Safety here is **progressive, not
9
+ restrictive**: the server defaults to read-only, `write` unlocks authoring,
10
+ and `unsafe` unlocks every tool with an exact per-call confirmation phrase for
11
+ each destructive operation. It starts offline, uses the supported n8n Public
12
+ API, and never sends workflow data to an external AI provider.
13
+
14
+ > **Release evidence:** install only a version that appears consistently on
15
+ > [npm](https://www.npmjs.com/package/n8n-mcp-community), the
16
+ > [latest GitHub release](https://github.com/drzamarian/n8n-mcp-community/releases/latest),
17
+ > and the MCP Registry entry `io.github.drzamarian/n8n-mcp-community`. A version
18
+ > in the source tree remains a candidate until those external readbacks agree.
19
+ > See [Installation](docs/installation.md).
13
20
 
14
21
  [Read the synthetic terminal demo transcript](docs/demo-transcript.md). It shows
15
- the planned exact-version startup, 44-tool inventory, and local Introspect
22
+ the exact-version startup, 44-tool inventory, and local Introspect
16
23
  diagnostics using only synthetic identifiers and documented output shapes.
17
24
 
18
- [![Animated synthetic terminal demonstration showing the planned exact-version startup, MCP inventory, and local Introspect result](docs/assets/demo.gif)](docs/demo-transcript.md)
25
+ [![Animated synthetic terminal demonstration showing the exact-version startup, MCP inventory, and local Introspect result](docs/assets/demo.gif)](docs/demo-transcript.md)
19
26
 
20
27
  ## Why this project
21
28
 
@@ -40,31 +47,71 @@ diagnostics using only synthetic identifiers and documented output shapes.
40
47
  The current source candidate has been verified with:
41
48
 
42
49
  - exactly **44 tools**, **5 resources**, and **4 prompts** over real stdio;
43
- - **271 passing tests** and the complete verification gate on Node.js 22.23.1
50
+ - **291 passing tests** and the complete verification gate on Node.js 22.23.1
44
51
  and 24.18.0;
45
- - zero findings from Gitleaks, Semgrep, Trivy, and both production/full
46
- `npm audit` runs; all three source scanners are reproduced in CI, with
47
- immutable scanner/action identities;
52
+ - zero findings from Gitleaks, Semgrep, Trivy, and both project-root
53
+ production/full `npm audit` runs; all three source scanners are reproduced in
54
+ CI, with immutable scanner/action identities;
55
+ - a separate no-override consumer install that resolves the reviewed
56
+ `@hono/node-server@1.19.15` backport, accepts only an exact
57
+ GHSA-frvp-7c67-39w9 advisory or a fully clean advisory readback while registry
58
+ metadata converges, and proves the stdio runtime and exact 44/5/4 inventory;
48
59
  - a reproducible dependency-license gate covering 224 installed package paths;
49
60
  - bounded same-origin HTTP contracts and zero-request policy-denial tests;
50
61
  - all 44 compiled tool lifecycles on disposable n8n Community 2.30.5 and 2.30.7
51
62
  instances with egress isolation, revoked keys, cleanup, and zero residue; and
52
- - a clean-installed npm tarball plus a byte-reproducible unsigned MCPB with the
53
- same compiled runtime and complete production dependency notices.
63
+ - a reviewed offline npm-tarball installation under the project's committed
64
+ dependency policy, plus a byte-reproducible unsigned MCPB with the same
65
+ compiled runtime and complete production dependency notices.
54
66
 
55
67
  Publication proceeds only through the reviewed release procedure: the
56
68
  repository-pinned MCPB signing identity, a human artifact-baseline receipt,
57
69
  tag-scoped protected-environment approval, and an externally signed MCPB
58
70
  handoff verified byte for byte against the reviewed unsigned candidate.
59
71
 
60
- ## Quick start from source
72
+ ## Quick start
61
73
 
62
74
  Requirements: Node.js 22 or 24, npm, a self-hosted n8n Community Edition
63
75
  instance, and an n8n Public API key with only the permissions you need.
64
76
 
77
+ First identify a version whose npm provenance and GitHub release assets you have
78
+ verified, then replace `<VERIFIED_VERSION>` below. Keep the pin exact:
79
+
80
+ ```json
81
+ {
82
+ "mcpServers": {
83
+ "n8n-community": {
84
+ "command": "npx",
85
+ "args": ["--yes", "n8n-mcp-community@<VERIFIED_VERSION>"],
86
+ "env": {
87
+ "N8N_API_URL": "https://n8n.example.com",
88
+ "N8N_API_KEY": "replace-with-a-dedicated-api-key",
89
+ "N8N_MCP_MODE": "read-only"
90
+ }
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ `read-only` is the recommended starting point, not a capability ceiling: the
97
+ same 44-tool surface includes full workflow authoring. Set
98
+ `N8N_MCP_MODE=write` to create and edit workflows, nodes, credentials, and
99
+ tags, or `N8N_MCP_MODE=unsafe` to enable every tool — activation, deletion,
100
+ retries, and the other destructive operations, each still guarded by an exact
101
+ per-call confirmation phrase. See [Safety modes](#safety-modes).
102
+
103
+ Restart the MCP client and confirm exactly 44 tools, 5 resources, and 4
104
+ prompts. Compatible desktop clients can instead install the signed MCPB from
105
+ the [latest verified release](https://github.com/drzamarian/n8n-mcp-community/releases/latest);
106
+ verify its checksum against the release `SHA256SUMS` first.
107
+
108
+ ## Quick start from source
109
+
110
+ Requirements: Node.js 22 or 24, npm, and a Git checkout of this repository.
111
+
65
112
  ```bash
66
113
  npm ci
67
- npm run build
114
+ npm run verify:contributor
68
115
  ```
69
116
 
70
117
  Configure your MCP client to run the compiled stdio entry point:
@@ -89,7 +136,7 @@ Restart the MCP client and list tools. The server can initialize and expose its
89
136
  inventory without n8n credentials; connected tools validate the URL and API key
90
137
  only when called.
91
138
 
92
- The release provides both a signed MCPB for compatible clients and
139
+ Verified releases provide both a signed MCPB for compatible clients and
93
140
  exact-version `npx` configuration for portability. `@latest`, global installs,
94
141
  and `curl | shell` are not reproducible defaults. See
95
142
  [Installation](docs/installation.md) for the release policy and client-specific
@@ -241,12 +288,13 @@ Read the complete [security model](docs/security-model.md) and use
241
288
 
242
289
  ```bash
243
290
  npm ci
244
- npm run check
291
+ npm run verify:contributor
245
292
  npm run sbom > sbom.cdx.json
246
293
  ```
247
294
 
248
- `npm run check` verifies formatting, dependency licenses/notices, strict
249
- TypeScript, all tests, the compiled runtime, and documentation inventory parity.
295
+ `npm run verify:contributor` runs the complete keyless contributor gate,
296
+ including formatting, dependency licenses/notices, strict TypeScript, all
297
+ tests, the compiled runtime, and documentation inventory parity.
250
298
  See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a change.
251
299
 
252
300
  ## Scope and non-affiliation
@@ -257,7 +305,7 @@ API. The project does not redistribute the `n8n-nodes-base` catalog and does not
257
305
  use browser cookies, interactive session routes, or runtime package downloads to
258
306
  construct one.
259
307
 
260
- The v0.1.0 surface intentionally excludes arbitrary workflow execution,
308
+ The v0.1.2 candidate surface intentionally excludes arbitrary workflow execution,
261
309
  credential/workflow transfer, folders, data tables, beta evaluation endpoints,
262
310
  and execution annotations. See the [roadmap](ROADMAP.md) for the annotation
263
311
  proposal retained outside the release target.
package/SECURITY.md CHANGED
@@ -6,16 +6,15 @@ before public disclosure.
6
6
 
7
7
  ## Supported versions
8
8
 
9
- No public version is supported until the first signed release. After release,
10
- the latest maintained minor line will receive security fixes. Older lines may be
11
- asked to upgrade when a safe backport is impractical.
9
+ The latest maintained minor line — currently 0.1.x — receives security fixes.
10
+ Older lines may be asked to upgrade when a safe backport is impractical.
12
11
 
13
12
  ## Report a vulnerability
14
13
 
15
- Use GitHub's **Security → Report a vulnerability** form. This repository will
16
- not be published until GitHub private vulnerability reporting is enabled and
17
- verified. Do not open a public issue with exploit details, credentials, workflow
18
- payloads, or identifying instance data.
14
+ Use GitHub's **Security → Report a vulnerability** form; private vulnerability
15
+ reporting is enabled and verified for this repository. Do not open a public
16
+ issue with exploit details, credentials, workflow payloads, or identifying
17
+ instance data.
19
18
 
20
19
  If the private form is unavailable, open a public issue containing only the
21
20
  sentence “Private security contact requested.” Do not include technical details
@@ -219,17 +219,28 @@ const DurationMetricsSchema = z.object({
219
219
  p99Ms: z.number().nonnegative().nullable(),
220
220
  });
221
221
  export const IntrospectResultSchema = z.object({
222
- schemaVersion: z.literal(INTROSPECT_SCHEMA_VERSION),
223
- engineVersion: z.literal(INTROSPECT_ENGINE_VERSION),
224
- status: z.enum(["complete", "partial"]),
225
- profile: z.enum(["quick", "deep"]),
226
- workflow: z.object({
222
+ schemaVersion: z
223
+ .literal(INTROSPECT_SCHEMA_VERSION)
224
+ .describe("Version of the stable structured introspect result contract."),
225
+ engineVersion: z
226
+ .literal(INTROSPECT_ENGINE_VERSION)
227
+ .describe("Version of the deterministic local analysis engine that produced this result."),
228
+ status: z
229
+ .enum(["complete", "partial"])
230
+ .describe("Whether all requested bounded evidence was collected and analyzed."),
231
+ profile: z
232
+ .enum(["quick", "deep"])
233
+ .describe("Effective quick or deep analysis profile used for this result."),
234
+ workflow: z
235
+ .object({
227
236
  id: z.string(),
228
237
  label: z.string().optional(),
229
238
  active: z.boolean(),
230
239
  nodeCount: z.number().int().nonnegative(),
231
- }),
232
- sample: z.object({
240
+ })
241
+ .describe("Sanitized identity and basic structure of the inspected workflow."),
242
+ sample: z
243
+ .object({
233
244
  lookbackHours: z.number().int().positive(),
234
245
  maxExecutions: z.number().int().positive(),
235
246
  metadataExecutions: z.number().int().nonnegative(),
@@ -249,8 +260,10 @@ export const IntrospectResultSchema = z.object({
249
260
  ]),
250
261
  oldestStartedAt: z.string().nullable(),
251
262
  newestStartedAt: z.string().nullable(),
252
- }),
253
- summary: z.object({
263
+ })
264
+ .describe("Exact execution-history scope, ordering confidence, requests, and byte budget used."),
265
+ summary: z
266
+ .object({
254
267
  findingCounts: z.object({
255
268
  critical: z.number().int().nonnegative(),
256
269
  high: z.number().int().nonnegative(),
@@ -271,8 +284,10 @@ export const IntrospectResultSchema = z.object({
271
284
  omittedCount: z.number().int().nonnegative(),
272
285
  truncated: z.boolean(),
273
286
  }),
274
- }),
275
- metrics: z.object({
287
+ })
288
+ .describe("Counts of findings, rule outcomes, and any details omitted by output bounds."),
289
+ metrics: z
290
+ .object({
276
291
  statusCounts: StatusCountsSchema,
277
292
  successRate: z.number().min(0).max(1).nullable(),
278
293
  duration: DurationMetricsSchema,
@@ -296,14 +311,24 @@ export const IntrospectResultSchema = z.object({
296
311
  sampledErrors: z.number().int().nonnegative(),
297
312
  }))
298
313
  .max(100),
299
- }),
300
- findings: z.array(FindingSchema).max(1_000),
301
- ruleCoverage: z.array(RuleCoverageSchema).max(100),
314
+ })
315
+ .describe("Factual status, duration, node-timing, and value-free error-cluster measurements."),
316
+ findings: z
317
+ .array(FindingSchema)
318
+ .max(1_000)
319
+ .describe("Ordered deterministic findings retained within the configured output limit."),
320
+ ruleCoverage: z
321
+ .array(RuleCoverageSchema)
322
+ .max(100)
323
+ .describe("Per-rule outcome and completeness evidence, including omitted finding counts."),
302
324
  limitations: z
303
325
  .array(z.object({ code: z.enum(INTROSPECT_LIMITATION_CODES), message: z.string() }))
304
- .max(100),
305
- guidance: z.object({
326
+ .max(100)
327
+ .describe("Collection or analysis limitations that constrain interpretation of this result."),
328
+ guidance: z
329
+ .object({
306
330
  instanceSecurityTool: z.literal("n8n_audit_generate"),
307
331
  message: z.string(),
308
- }),
332
+ })
333
+ .describe("Boundary guidance pointing to the separate instance-level n8n security audit tool."),
309
334
  });
@@ -38,6 +38,8 @@ const VALUE_REDACTIONS = [
38
38
  [/\bpix[\s:-]*[\w.@-]{8,}/gi, "[PIX]"],
39
39
  [/\b(?:[A-Z0-9]+_)*(?:SECRET|TOKEN|API_KEY)(?:_[A-Z0-9]+)+\b/gi, "[SECRET]"],
40
40
  ];
41
+ const OPAQUE_PATH_CHARACTERS = /^[A-Za-z0-9._~+/=-]+$/;
42
+ const OPAQUE_HEXADECIMAL_PATH = /^[A-Fa-f0-9]{24,64}$/;
41
43
  const INJECTION_PATTERNS = [
42
44
  [
43
45
  /\b(?:ignore|disregard|forget|override)\s+(?:all\s+)?(?:previous|prior|above)\s+instructions?\b/gi,
@@ -46,6 +48,12 @@ const INJECTION_PATTERNS = [
46
48
  [/\b(?:system|assistant|developer)\s*:\s/gi, "[FILTERED-ROLE]: "],
47
49
  [/<\/?(?:system|assistant|developer|prompt|instructions?)>/gi, "[FILTERED-TAG]"],
48
50
  ];
51
+ export class OutputLimitError extends Error {
52
+ constructor() {
53
+ super("The sanitized result exceeds the output limit. Use narrower filters or pagination.");
54
+ this.name = "OutputLimitError";
55
+ }
56
+ }
49
57
  function compareStrings(left, right) {
50
58
  return left < right ? -1 : left > right ? 1 : 0;
51
59
  }
@@ -77,6 +85,24 @@ function redactUrlCredential(value) {
77
85
  // quantifier is bounded so a long delimiter-free run cannot force quadratic backtracking.
78
86
  return value.replace(/([A-Za-z][A-Za-z0-9+.-]{0,31}:\/\/)[^/?#\s]+@/g, "$1[REDACTED]@");
79
87
  }
88
+ function looksLikeOpaquePathSegment(value) {
89
+ if (value.length < 24 || !OPAQUE_PATH_CHARACTERS.test(value))
90
+ return false;
91
+ // Encoding-aware detection closes the entropy blind spot for fixed-width hexadecimal IDs:
92
+ // repeated hex digits can carry 96+ bits while scoring below the generic Shannon threshold.
93
+ if (OPAQUE_HEXADECIMAL_PATH.test(value))
94
+ return true;
95
+ const frequencies = new Map();
96
+ for (const character of value) {
97
+ frequencies.set(character, (frequencies.get(character) ?? 0) + 1);
98
+ }
99
+ let entropy = 0;
100
+ for (const count of frequencies.values()) {
101
+ const probability = count / value.length;
102
+ entropy -= probability * Math.log2(probability);
103
+ }
104
+ return entropy >= (value.length >= 32 ? 3.5 : 4);
105
+ }
80
106
  function sanitizeString(value, structuralKey) {
81
107
  const boundedInput = value.length > 131_072 ? value.slice(0, 131_072) : value;
82
108
  const normalizedInput = boundedInput.normalize("NFKC");
@@ -150,10 +176,11 @@ function sanitizeString(value, structuralKey) {
150
176
  }
151
177
  return { value: output, redacted };
152
178
  }
153
- function sanitizeValue(value, state, depth, structuralKey, preserveChildKeys = false, sensitiveDescriptor = false) {
179
+ function sanitizeValue(value, state, depth, structuralKey, preserveChildKeys = false, sensitiveDescriptor = false, treatSensitiveKeyAsStructural = false) {
154
180
  state.nodes += 1;
155
181
  if (state.nodes > 20_000 || depth > 20) {
156
182
  state.redacted = true;
183
+ state.structurallyReduced = true;
157
184
  return "[TRUNCATED]";
158
185
  }
159
186
  if (typeof value === "string") {
@@ -165,18 +192,22 @@ function sanitizeValue(value, state, depth, structuralKey, preserveChildKeys = f
165
192
  return value;
166
193
  if (Array.isArray(value)) {
167
194
  const limit = Math.min(value.length, 1_000);
168
- if (limit < value.length)
195
+ if (limit < value.length) {
169
196
  state.redacted = true;
197
+ state.structurallyReduced = true;
198
+ }
170
199
  return value
171
200
  .slice(0, limit)
172
201
  .map((item) => sanitizeValue(item, state, depth + 1, structuralKey, false, sensitiveDescriptor));
173
202
  }
174
203
  if (typeof value === "bigint") {
175
204
  state.redacted = true;
205
+ state.structurallyReduced = true;
176
206
  return value.toString();
177
207
  }
178
208
  if (typeof value !== "object") {
179
209
  state.redacted = true;
210
+ state.structurallyReduced = true;
180
211
  return "[UNSUPPORTED]";
181
212
  }
182
213
  const prototype = Reflect.getPrototypeOf(value);
@@ -185,6 +216,7 @@ function sanitizeValue(value, state, depth, structuralKey, preserveChildKeys = f
185
216
  value instanceof Set ||
186
217
  (prototype !== Object.prototype && prototype !== null)) {
187
218
  state.redacted = true;
219
+ state.structurallyReduced = true;
188
220
  return "[UNSUPPORTED_OBJECT]";
189
221
  }
190
222
  const output = Object.create(null);
@@ -192,50 +224,75 @@ function sanitizeValue(value, state, depth, structuralKey, preserveChildKeys = f
192
224
  const sensitiveSibling = entries.some(([key, candidate]) => (key === "key" || key === "name") &&
193
225
  typeof candidate === "string" &&
194
226
  SENSITIVE_KEY.test(candidate));
195
- for (const [key, child] of entries.slice(0, 1_000)) {
227
+ const originalKeys = new Set(entries.map(([key]) => key));
228
+ for (const [entryIndex, [key, child]] of entries.slice(0, 1_000).entries()) {
196
229
  if (PROTOTYPE_KEY.test(key)) {
197
230
  state.redacted = true;
198
231
  continue;
199
232
  }
200
- if (!preserveChildKeys && SENSITIVE_KEY.test(key) && !STRUCTURAL_IDENTIFIER_KEY.test(key)) {
201
- output[key] = "[REDACTED]";
233
+ const sanitizedKey = sanitizeString(key);
234
+ let outputKey = key;
235
+ if (sanitizedKey.redacted) {
236
+ let placeholderIndex = entryIndex + 1;
237
+ do {
238
+ outputKey = `[REDACTED_KEY_${placeholderIndex}]`;
239
+ placeholderIndex += 1;
240
+ } while (originalKeys.has(outputKey) || Object.hasOwn(output, outputKey));
241
+ state.redacted = true;
242
+ state.structurallyReduced = true;
243
+ }
244
+ if (!treatSensitiveKeyAsStructural &&
245
+ !preserveChildKeys &&
246
+ SENSITIVE_KEY.test(key) &&
247
+ !STRUCTURAL_IDENTIFIER_KEY.test(key)) {
248
+ output[outputKey] = "[REDACTED]";
202
249
  state.redacted = true;
203
250
  continue;
204
251
  }
205
252
  if (sensitiveDescriptor && SENSITIVE_DESCRIPTOR_VALUE_KEY.test(key)) {
206
- output[key] = "[REDACTED]";
253
+ output[outputKey] = "[REDACTED]";
207
254
  state.redacted = true;
208
255
  continue;
209
256
  }
210
257
  if (sensitiveDescriptor && child !== null && typeof child === "object") {
211
- output[key] = "[REDACTED]";
258
+ output[outputKey] = "[REDACTED]";
212
259
  state.redacted = true;
213
260
  continue;
214
261
  }
215
262
  if (key === "value" && sensitiveSibling) {
216
- output[key] = "[REDACTED]";
263
+ output[outputKey] = "[REDACTED]";
217
264
  state.redacted = true;
218
265
  continue;
219
266
  }
220
- if (preserveChildKeys && SENSITIVE_KEY.test(key)) {
267
+ if (preserveChildKeys &&
268
+ (SENSITIVE_KEY.test(key) || (structuralKey === "properties" && sanitizedKey.redacted))) {
221
269
  if (structuralKey === "properties" && child !== null && typeof child === "object") {
222
- output[key] = sanitizeValue(child, state, depth + 1, key, false, true);
270
+ output[outputKey] = sanitizeValue(child, state, depth + 1, key, false, true);
223
271
  }
224
272
  else {
225
- output[key] = "[REDACTED]";
273
+ output[outputKey] = "[REDACTED]";
226
274
  state.redacted = true;
227
275
  }
228
276
  continue;
229
277
  }
230
- output[key] = sanitizeValue(child, state, depth + 1, key, STRUCTURAL_MAP_KEY.test(key), false);
278
+ output[outputKey] = sanitizeValue(child, state, depth + 1, key, STRUCTURAL_MAP_KEY.test(key), false);
231
279
  }
232
- if (entries.length > 1_000)
280
+ if (entries.length > 1_000) {
233
281
  state.redacted = true;
282
+ state.structurallyReduced = true;
283
+ }
234
284
  return output;
235
285
  }
236
- export function sanitizeForOutput(value) {
237
- const state = { nodes: 0, redacted: false };
238
- return { data: sanitizeValue(value, state, 0), redacted: state.redacted, untrusted: true };
286
+ export function sanitizeForOutputDetailed(value, options = {}) {
287
+ const state = { nodes: 0, redacted: false, structurallyReduced: false };
288
+ const data = sanitizeValue(value, state, 0, undefined, false, false, options.preserveValidatedRootRecordValues ?? false);
289
+ return {
290
+ output: { data, redacted: state.redacted, untrusted: true },
291
+ structurallyReduced: state.structurallyReduced,
292
+ };
293
+ }
294
+ export function sanitizeForOutput(value, options = {}) {
295
+ return sanitizeForOutputDetailed(value, options).output;
239
296
  }
240
297
  export function sanitizeLabelForOutput(value, maxLength = 120) {
241
298
  const normalized = value.slice(0, 4_096).normalize("NFKC").slice(0, 4_096);
@@ -244,10 +301,29 @@ export function sanitizeLabelForOutput(value, maxLength = 120) {
244
301
  ? sanitized
245
302
  : `${sanitized.slice(0, Math.max(0, maxLength - 1))}\u2026`;
246
303
  }
304
+ export function sanitizePathSegmentForOutput(value, maxLength = 64) {
305
+ const normalized = value.slice(0, 4_096).normalize("NFKC").slice(0, 4_096);
306
+ if (SENSITIVE_KEY.test(normalized) ||
307
+ PROTOTYPE_KEY.test(normalized) ||
308
+ looksLikeOpaquePathSegment(normalized)) {
309
+ return { value: "[REDACTED]", redacted: true };
310
+ }
311
+ const sanitized = sanitizeString(normalized);
312
+ const compact = sanitized.value.replace(/\s+/g, " ").trim();
313
+ if (sanitized.redacted || compact.length === 0) {
314
+ return { value: "[REDACTED]", redacted: true };
315
+ }
316
+ return {
317
+ value: compact.length <= maxLength
318
+ ? compact
319
+ : `${compact.slice(0, Math.max(0, maxLength - 1))}\u2026`,
320
+ redacted: compact.length > maxLength,
321
+ };
322
+ }
247
323
  export function boundedJson(value, maxBytes = 256 * 1024) {
248
324
  const text = JSON.stringify(value, null, 2);
249
325
  if (Buffer.byteLength(text) > maxBytes) {
250
- throw new Error("The sanitized result exceeds the output limit. Use narrower filters or pagination.");
326
+ throw new OutputLimitError();
251
327
  }
252
328
  return text;
253
329
  }
@@ -23,32 +23,3 @@ export function requireSafeAscii(value, label, maximumLength = 256) {
23
23
  }
24
24
  return value;
25
25
  }
26
- export function projectMetadata(value) {
27
- const source = z.record(z.unknown()).parse(value);
28
- const allowed = [
29
- "id",
30
- "name",
31
- "type",
32
- "active",
33
- "isArchived",
34
- "createdAt",
35
- "updatedAt",
36
- "versionId",
37
- "workflowId",
38
- "status",
39
- "startedAt",
40
- "stoppedAt",
41
- "mode",
42
- "finished",
43
- "retryOf",
44
- "retrySuccessId",
45
- "role",
46
- "disabled",
47
- ];
48
- const output = {};
49
- for (const key of allowed) {
50
- if (Object.hasOwn(source, key))
51
- output[key] = source[key];
52
- }
53
- return output;
54
- }