cap-mcp-guard 0.3.0 → 0.3.1

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.
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
3
4
  const path = require('path');
4
5
 
5
6
  const { attachInterceptor } = require('../core/interceptor');
@@ -7,23 +8,42 @@ const { loadConfig, NOT_CONFIGURED_PREFIX } = require('../policy/config');
7
8
  const { logAudit } = require('../audit/log');
8
9
  const { exportSpan } = require('../otel/exporter');
9
10
 
11
+ // Versions before 0.3.0 read policy from this file instead of package.json. It is no
12
+ // longer read at all — see resolvePolicyDefinition()'s legacy-file check below, which
13
+ // exists specifically so an unmigrated project fails loudly instead of silently running
14
+ // with zero enforcement.
15
+ const LEGACY_YAML_FILENAME = 'cap-mcp-guard.yaml';
16
+
10
17
  /**
11
18
  * Loads the `"cap-mcp-guard"` key from the host CAP project's package.json.
12
19
  * A missing package.json or a missing key is a valid "not configured yet"
13
20
  * state — falls back to a pass-through PolicyDefinition (every entity is
14
- * opt-in, so this is equivalent to no policy at all). A config that exists
15
- * but fails to parse is a user mistake and is NOT swallowed — it propagates
16
- * so the bad config gets noticed rather than silently ignored.
21
+ * opt-in, so this is equivalent to no policy at all), UNLESS a legacy
22
+ * `cap-mcp-guard.yaml` is sitting right there (see LEGACY_YAML_FILENAME): that
23
+ * almost certainly means an unmigrated pre-0.3.0 project, and starting up
24
+ * silently unenforced would be worse than refusing to start. A config that
25
+ * exists but fails to parse is a user mistake and is NOT swallowed either —
26
+ * it propagates so the bad config gets noticed rather than silently ignored.
17
27
  *
18
28
  * @param {object} cds the @sap/cds module (used only for cds.root)
19
29
  */
20
30
  function resolvePolicyDefinition(cds) {
21
- const configPath = path.join(cds.root || process.cwd(), 'package.json');
31
+ const root = cds.root || process.cwd();
32
+ const configPath = path.join(root, 'package.json');
22
33
 
23
34
  try {
24
35
  return loadConfig(configPath);
25
36
  } catch (err) {
26
37
  if (err.message.startsWith(NOT_CONFIGURED_PREFIX)) {
38
+ const legacyYamlPath = path.join(root, LEGACY_YAML_FILENAME);
39
+ if (fs.existsSync(legacyYamlPath)) {
40
+ throw new Error(
41
+ `[cap-mcp-guard] found "${LEGACY_YAML_FILENAME}" at ${legacyYamlPath}, but this file has not been read ` +
42
+ 'since 0.3.0 — config now lives under the "cap-mcp-guard" key in package.json. Move its contents there ' +
43
+ '(or delete the file, if you no longer want a policy) before starting the server. Refusing to start ' +
44
+ 'rather than run with zero enforcement silently.'
45
+ );
46
+ }
27
47
  console.warn('[cap-mcp-guard] no config found, running in pass-through mode (no policies enforced)');
28
48
  return { mode: 'observe', entities: {} };
29
49
  }
@@ -46,7 +66,10 @@ function resolvePolicyDefinition(cds) {
46
66
  * `"cap-mcp-guard"` key in the project's package.json (see resolvePolicyDefinition)
47
67
  * @param {object|false} [options.audit] forwarded to logAudit()'s write
48
68
  * options (`{ stdout, filePath }`); pass `false` to disable audit
49
- * logging entirely.
69
+ * logging entirely. When omitted, falls back to the `"audit"` key parsed
70
+ * from package.json's `"cap-mcp-guard"` config (see lib/policy/config.js) —
71
+ * this is what lets `audit.filePath` be set through config alone, without
72
+ * hand-writing a registerCapMcpGuard() call.
50
73
  * @param {object|false} [options.otel] forwarded to exportSpan()'s options
51
74
  * (`{ tracer }`); pass `false` to skip OTel span export entirely. With
52
75
  * no host-configured OTel SDK this is already a harmless no-op, so the
@@ -70,7 +93,8 @@ function resolvePolicyDefinition(cds) {
70
93
  */
71
94
  function registerCapMcpGuard(cds, options = {}) {
72
95
  const policyDefinition = options.policyDefinition || resolvePolicyDefinition(cds);
73
- const { onDecision: userOnDecision, audit, otel } = options;
96
+ const { onDecision: userOnDecision, otel } = options;
97
+ const audit = options.audit !== undefined ? options.audit : policyDefinition.audit;
74
98
 
75
99
  const pseudonymSecret = options.pseudonymSecret || process.env.CAP_MCP_GUARD_PSEUDONYM_SECRET;
76
100
  const usesPseudonymize = Object.values(policyDefinition.entities).some(
@@ -99,6 +99,67 @@ function applyPseudonymize(results, fieldsToPseudonymize, secret) {
99
99
  });
100
100
  }
101
101
 
102
+ /**
103
+ * Finds the association/composition elements on a CAP entity definition, along with the
104
+ * (already service-qualified) entity name each one targets — e.g. for `Employees.department`,
105
+ * `{ name: 'department', targetEntity: 'CatalogService.Departments' }`. That target name is
106
+ * exactly what a request against `Departments` directly would resolve via resolveEntity(),
107
+ * so the same `policyDefinition.entities` keys apply to nested rows without any separate
108
+ * EDM/$metadata resolution — CAP's own compiled model already carries it.
109
+ *
110
+ * @param {object} target req.target — a linked CSN entity definition, or undefined
111
+ * @returns {{name: string, targetEntity: string}[]}
112
+ */
113
+ function findAssociationElements(target) {
114
+ if (!target || !target.elements) return [];
115
+
116
+ return Object.entries(target.elements)
117
+ .filter(([, el]) => el && (el.type === 'cds.Association' || el.type === 'cds.Composition'))
118
+ .map(([name, el]) => ({
119
+ name,
120
+ targetEntity: (el._target && el._target.name) || el.target
121
+ }))
122
+ .filter((assoc) => assoc.targetEntity);
123
+ }
124
+
125
+ /**
126
+ * Applies each expanded association's own policy (mask/pseudonymize) to the nested
127
+ * row(s) hanging off of it — one level deep, matching what `$expand` actually returns.
128
+ * Mutates `results` in place, same convention as applyMask()/applyPseudonymize(). A no-op
129
+ * when the request's entity has no associations, or none of them were expanded (the nav
130
+ * property is simply absent from the row), or the target entity has no policy configured.
131
+ *
132
+ * @param {object|object[]} results
133
+ * @param {object} target req.target — see findAssociationElements()
134
+ * @param {object} policyDefinition see lib/policy/config.js
135
+ * @param {string} [pseudonymSecret] forwarded to applyPseudonymize() for nested fields
136
+ */
137
+ function applyNestedPolicy(results, target, policyDefinition, pseudonymSecret) {
138
+ const associations = findAssociationElements(target);
139
+ if (!associations.length) return;
140
+
141
+ const rows = Array.isArray(results) ? results : [results];
142
+
143
+ for (const row of rows) {
144
+ if (!row || typeof row !== 'object') continue;
145
+
146
+ for (const { name, targetEntity } of associations) {
147
+ const navValue = row[name];
148
+ if (navValue === undefined || navValue === null) continue;
149
+
150
+ const entityConfig = policyDefinition.entities[targetEntity];
151
+ if (!entityConfig) continue;
152
+
153
+ if (entityConfig.mask && entityConfig.mask.length > 0) {
154
+ applyMask(navValue, entityConfig.mask);
155
+ }
156
+ if (entityConfig.pseudonymize && entityConfig.pseudonymize.length > 0) {
157
+ applyPseudonymize(navValue, entityConfig.pseudonymize, pseudonymSecret);
158
+ }
159
+ }
160
+ }
161
+ }
162
+
102
163
  /**
103
164
  * Attaches request interception to a CAP service instance, producing a
104
165
  * guard context (see context.js) for every request that passes through it.
@@ -134,9 +195,47 @@ function attachInterceptor(srv, options = {}) {
134
195
  const { onContext, policyDefinition, onDecision, now, pseudonymSecret } = options;
135
196
  const clockDeps = now ? { now } : {};
136
197
 
198
+ function buildRequestContext(req, extra) {
199
+ return buildContext(
200
+ {
201
+ ...extractAgentInfo(req),
202
+ ...extractTraceContext(req),
203
+ entity: resolveEntity(req),
204
+ operation: resolveOperation(req),
205
+ tenant: req.tenant,
206
+ user: req.user && req.user.id,
207
+ session: req.id,
208
+ ...extra
209
+ },
210
+ clockDeps
211
+ );
212
+ }
213
+
137
214
  srv.before('*', (req) => {
138
215
  if (!req) return;
139
216
  req._mcpGuardStartedAt = process.hrtime.bigint();
217
+
218
+ if (!policyDefinition) return;
219
+
220
+ // allowTools is a gate, not a masking rule — it has to be enforced before the
221
+ // query runs, not after. rowCount/durationMs aren't known yet, but `allowed`
222
+ // never depends on either, so this decision is already final.
223
+ const context = buildRequestContext(req, {});
224
+ const decision = evaluate(context, policyDefinition);
225
+
226
+ if (decision.mode === 'enforce' && !decision.allowed) {
227
+ // req.reject() throws in real CAP requests, so srv.after('*') below never runs
228
+ // for a denied request — report it here instead, or the denial would never be
229
+ // audited/traced at all.
230
+ if (typeof onContext === 'function') onContext(context, req);
231
+ if (typeof onDecision === 'function') onDecision(decision, context, req);
232
+
233
+ if (typeof req.reject === 'function') {
234
+ req.reject(403, decision.reason);
235
+ } else {
236
+ throw new Error(decision.reason);
237
+ }
238
+ }
140
239
  });
141
240
 
142
241
  srv.after('*', (results, req) => {
@@ -147,20 +246,7 @@ function attachInterceptor(srv, options = {}) {
147
246
  ? undefined
148
247
  : Number(process.hrtime.bigint() - startedAt) / 1e6;
149
248
 
150
- const context = buildContext(
151
- {
152
- ...extractAgentInfo(req),
153
- ...extractTraceContext(req),
154
- entity: resolveEntity(req),
155
- operation: resolveOperation(req),
156
- tenant: req.tenant,
157
- user: req.user && req.user.id,
158
- session: req.id,
159
- rowCount: resolveRowCount(results),
160
- durationMs
161
- },
162
- clockDeps
163
- );
249
+ const context = buildRequestContext(req, { rowCount: resolveRowCount(results), durationMs });
164
250
 
165
251
  if (typeof onContext === 'function') {
166
252
  onContext(context, req);
@@ -177,6 +263,14 @@ function attachInterceptor(srv, options = {}) {
177
263
  applyPseudonymize(results, decision.fieldsToPseudonymize, pseudonymSecret);
178
264
  }
179
265
 
266
+ if (decision.mode === 'enforce' && decision.rowLimitExceeded && Array.isArray(results)) {
267
+ results.length = decision.maxRows;
268
+ }
269
+
270
+ if (decision.mode === 'enforce') {
271
+ applyNestedPolicy(results, req.target, policyDefinition, pseudonymSecret);
272
+ }
273
+
180
274
  if (typeof onDecision === 'function') {
181
275
  onDecision(decision, context, req);
182
276
  }
@@ -14,6 +14,31 @@ function typeOf(value) {
14
14
  return typeof value;
15
15
  }
16
16
 
17
+ /**
18
+ * Validates the optional top-level `"audit"` key — the same shape
19
+ * lib/audit/log.js's writeAuditEntry() options take (`{ stdout, filePath }`).
20
+ * Exposing it in package.json is what lets audit-log persistence be turned on
21
+ * through config alone, without hand-writing a registerCapMcpGuard() call
22
+ * (see lib/adapters/cap.js, which forwards this to onDecision's audit option).
23
+ */
24
+ function validateAuditConfig(raw) {
25
+ if (raw === undefined) return undefined;
26
+
27
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
28
+ throw new Error(`"audit" must be a mapping, got ${typeOf(raw)}`);
29
+ }
30
+
31
+ const { filePath, stdout } = raw;
32
+ if (filePath !== undefined && typeof filePath !== 'string') {
33
+ throw new Error(`"audit.filePath" must be a string, got ${typeOf(filePath)}`);
34
+ }
35
+ if (stdout !== undefined && typeof stdout !== 'boolean') {
36
+ throw new Error(`"audit.stdout" must be a boolean, got ${typeOf(stdout)}`);
37
+ }
38
+
39
+ return { ...(filePath !== undefined && { filePath }), ...(stdout !== undefined && { stdout }) };
40
+ }
41
+
17
42
  function validatePseudonymizeEntry(entityName, entry) {
18
43
  if (typeof entry === 'string') {
19
44
  return { field: entry, type: 'opaque' };
@@ -91,7 +116,7 @@ function validateEntityConfig(name, raw) {
91
116
  * @param {object} [opts]
92
117
  * @param {string} [opts.source] label used in error messages (defaults to
93
118
  * the conventional file name; loadConfig() passes the real path instead)
94
- * @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined) }}
119
+ * @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined), audit: (object|undefined) }}
95
120
  */
96
121
  function parseConfig(raw, opts = {}) {
97
122
  const { source = 'package.json' } = opts;
@@ -122,7 +147,9 @@ function parseConfig(raw, opts = {}) {
122
147
  throw new Error(`"users" must be an array, got ${typeOf(raw.users)}`);
123
148
  }
124
149
 
125
- return { mode: raw.mode, entities, services: raw.services, users: raw.users };
150
+ const audit = validateAuditConfig(raw.audit);
151
+
152
+ return { mode: raw.mode, entities, services: raw.services, users: raw.users, audit };
126
153
  }
127
154
 
128
155
  /**
@@ -133,7 +160,7 @@ function parseConfig(raw, opts = {}) {
133
160
  * with a single startsWith() check and fall back to pass-through mode.
134
161
  *
135
162
  * @param {string} packageJsonPath
136
- * @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined) }}
163
+ * @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined), audit: (object|undefined) }}
137
164
  */
138
165
  function loadConfig(packageJsonPath) {
139
166
  let content;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cap-mcp-guard",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "A CDS plugin — the trust layer for AI agents accessing SAP CAP business data — request interception, policy enforcement, field masking/pseudonymization, and OpenTelemetry-native observability for MCP-enabled applications.",
5
5
  "main": "lib/index.js",
6
6
  "author": "Furkan Aksoy <furkanaksy838@gmail.com>",