cap-mcp-guard 0.3.0 → 0.4.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/README.md CHANGED
@@ -184,6 +184,32 @@ to load if it is). More typed generators (e.g. a Luhn-valid fake credit card num
184
184
  added later without changing this config shape — anything without a dedicated generator
185
185
  just falls back to `"opaque"`.
186
186
 
187
+ Sometimes you don't want a *derived* fake value at all — just one fixed, human-chosen
188
+ replacement, the same for every row (e.g. a shared support alias instead of each customer's
189
+ real email). Use `type: "custom"` with a required `"value"`:
190
+
191
+ ```json
192
+ {
193
+ "cap-mcp-guard": {
194
+ "mode": "enforce",
195
+ "entities": {
196
+ "Customers": {
197
+ "pseudonymize": [{ "field": "Email", "type": "custom", "value": "hidden@example.com" }]
198
+ }
199
+ }
200
+ }
201
+ }
202
+ ```
203
+
204
+ Unlike `"opaque"`/`"iban"`, `"custom"` doesn't derive anything from the real value or the
205
+ pseudonym secret — every row gets the exact same literal, so it doesn't need
206
+ `CAP_MCP_GUARD_PSEUDONYM_SECRET` at all (a config that mixes a `"custom"` entry with a
207
+ non-`"custom"` one on the same or another entity still needs the secret, for the other
208
+ entry). Because every real value maps to the same output, `"custom"` gives up the
209
+ relational structure (telling two customers apart) that `"opaque"`/`"iban"` preserve — it's
210
+ closer in effect to `mask`, just with your own replacement string instead of the fixed
211
+ `'***MASKED***'`.
212
+
187
213
  **Requires a secret.** Set the `CAP_MCP_GUARD_PSEUDONYM_SECRET` environment variable (or
188
214
  pass `pseudonymSecret` directly to `registerCapMcpGuard`) — every pseudonym is derived from
189
215
  it via HMAC, so without it a fake value can't be reproduced or tied back to a real one.
@@ -194,7 +220,8 @@ fake one from then on) — this is expected, not a bug.
194
220
 
195
221
  ## What you get, per request
196
222
 
197
- - **Masking** — in `enforce` mode, fields listed under `mask` are replaced with `'***MASKED***'`, and fields under `pseudonymize` with a deterministic fake value (see above), in the real response. In `observe` mode nothing is touched; the guard only computes what *would* happen.
223
+ - **Masking** — in `enforce` mode, fields listed under `mask` are replaced with `'***MASKED***'`, and fields under `pseudonymize` with a deterministic fake value (see above), in the real response. In `observe` mode nothing is touched; the guard only computes what *would* happen. Also applied one level deep to any `$expand`ed association whose target entity has its own policy.
224
+ - **Tool/row enforcement** — in `enforce` mode, a request naming an operation outside `allowTools` is rejected with a 403 before it runs; a response exceeding `maxRows` is truncated to that limit. In `observe` mode both are only computed and reported, never applied.
198
225
  - **Audit log** — every request produces a structured JSON line (Context + Decision), to stdout and/or a file you choose.
199
226
  - **OpenTelemetry spans** — every request also becomes a real span via `@opentelemetry/api`. If your app already has an OTel SDK configured (any OTLP-compatible backend — Grafana, Jaeger, Datadog, SAP Cloud Logging), the guard's spans just show up there, correctly linked into the caller's trace via W3C Trace Context (`traceparent`/`tracestate`) when present — no extra mapping needed, because the context schema was built against OTel's GenAI semantic conventions (`gen_ai.*`) from the start.
200
227
 
@@ -217,7 +244,6 @@ npm start # boots a real server at localhost:4004 — flip package.json's "cap
217
244
  - `@mcp.policy`-style CDS annotations as an alternative to the `"cap-mcp-guard"` package.json config
218
245
  - Approval workflows (human-in-the-loop for sensitive operations)
219
246
  - Rate limiting and a dashboard UI
220
- - Actually blocking a request when `allowTools`/`maxRows` is violated (today those are computed and reported, not enforced)
221
247
 
222
248
  ## Development
223
249
 
@@ -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,13 +93,16 @@ 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
 
99
+ // type: "custom" is a fixed, operator-chosen literal, not a derived value — it needs no
100
+ // secret to reproduce, so a config using only "custom" entries can start without one.
75
101
  const pseudonymSecret = options.pseudonymSecret || process.env.CAP_MCP_GUARD_PSEUDONYM_SECRET;
76
- const usesPseudonymize = Object.values(policyDefinition.entities).some(
77
- (entityConfig) => entityConfig.pseudonymize && entityConfig.pseudonymize.length > 0
102
+ const needsPseudonymSecret = Object.values(policyDefinition.entities).some((entityConfig) =>
103
+ (entityConfig.pseudonymize || []).some((entry) => entry.type !== 'custom')
78
104
  );
79
- if (usesPseudonymize && !pseudonymSecret) {
105
+ if (needsPseudonymSecret && !pseudonymSecret) {
80
106
  throw new Error(
81
107
  'CAP_MCP_GUARD_PSEUDONYM_SECRET env var (or options.pseudonymSecret) is required when "pseudonymize" is configured'
82
108
  );
@@ -91,14 +91,75 @@ function applyPseudonymize(results, fieldsToPseudonymize, secret) {
91
91
 
92
92
  items.forEach((item) => {
93
93
  if (!item || typeof item !== 'object') return;
94
- for (const { field, type } of fieldsToPseudonymize) {
94
+ for (const { field, type, value: customValue } of fieldsToPseudonymize) {
95
95
  if (Object.prototype.hasOwnProperty.call(item, field)) {
96
- item[field] = generatePseudonym(field, item[field], type, secret);
96
+ item[field] = generatePseudonym(field, item[field], type, secret, customValue);
97
97
  }
98
98
  }
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,13 +14,38 @@ 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' };
20
45
  }
21
46
 
22
47
  if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
23
- const { field, type = 'opaque' } = entry;
48
+ const { field, type = 'opaque', value } = entry;
24
49
 
25
50
  if (typeof field !== 'string' || !field) {
26
51
  throw new Error(`entities.${entityName}.pseudonymize entries must have a "field" string`);
@@ -30,6 +55,15 @@ function validatePseudonymizeEntry(entityName, entry) {
30
55
  `entities.${entityName}.pseudonymize.${field}.type must be one of ${PSEUDONYM_TYPES.join(', ')} (got ${JSON.stringify(type)})`
31
56
  );
32
57
  }
58
+ if (type === 'custom') {
59
+ if (typeof value !== 'string' || !value) {
60
+ throw new Error(`entities.${entityName}.pseudonymize.${field}: type "custom" requires a non-empty "value" string`);
61
+ }
62
+ return { field, type, value };
63
+ }
64
+ if (value !== undefined) {
65
+ throw new Error(`entities.${entityName}.pseudonymize.${field}: "value" is only allowed with type "custom"`);
66
+ }
33
67
  return { field, type };
34
68
  }
35
69
 
@@ -91,7 +125,7 @@ function validateEntityConfig(name, raw) {
91
125
  * @param {object} [opts]
92
126
  * @param {string} [opts.source] label used in error messages (defaults to
93
127
  * the conventional file name; loadConfig() passes the real path instead)
94
- * @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined) }}
128
+ * @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined), audit: (object|undefined) }}
95
129
  */
96
130
  function parseConfig(raw, opts = {}) {
97
131
  const { source = 'package.json' } = opts;
@@ -122,7 +156,9 @@ function parseConfig(raw, opts = {}) {
122
156
  throw new Error(`"users" must be an array, got ${typeOf(raw.users)}`);
123
157
  }
124
158
 
125
- return { mode: raw.mode, entities, services: raw.services, users: raw.users };
159
+ const audit = validateAuditConfig(raw.audit);
160
+
161
+ return { mode: raw.mode, entities, services: raw.services, users: raw.users, audit };
126
162
  }
127
163
 
128
164
  /**
@@ -133,7 +169,7 @@ function parseConfig(raw, opts = {}) {
133
169
  * with a single startsWith() check and fall back to pass-through mode.
134
170
  *
135
171
  * @param {string} packageJsonPath
136
- * @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined) }}
172
+ * @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined), audit: (object|undefined) }}
137
173
  */
138
174
  function loadConfig(packageJsonPath) {
139
175
  let content;
@@ -2,7 +2,7 @@
2
2
 
3
3
  const crypto = require('crypto');
4
4
 
5
- const PSEUDONYM_TYPES = ['opaque', 'iban'];
5
+ const PSEUDONYM_TYPES = ['opaque', 'iban', 'custom'];
6
6
 
7
7
  const IBAN_SHAPE = /^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/;
8
8
 
@@ -67,23 +67,29 @@ function generateIbanPseudonym(value, secret) {
67
67
 
68
68
  const GENERATORS = {
69
69
  opaque: generateOpaquePseudonym,
70
- iban: (field, value, secret) => generateIbanPseudonym(value, secret)
70
+ iban: (field, value, secret) => generateIbanPseudonym(value, secret),
71
+ // Not actually a generator — every row gets the exact same operator-chosen literal
72
+ // instead of a derived fake value, so there's nothing to compute from value/secret.
73
+ custom: (field, value, secret, customValue) => customValue
71
74
  };
72
75
 
73
76
  /**
74
77
  * @param {string} field the field name being pseudonymized
75
78
  * @param {*} value the real value
76
79
  * @param {string} type one of PSEUDONYM_TYPES (validated by lib/policy/config.js)
77
- * @param {string} secret never embedded in the output; without it, the pseudonym can't
78
- * be reproduced or (for the opaque case) traced back to a specific value
80
+ * @param {string} [secret] never embedded in the output; without it, the pseudonym can't
81
+ * be reproduced or (for the opaque/iban case) traced back to a specific value. Unused
82
+ * (and not required) for type "custom".
83
+ * @param {string} [customValue] the literal replacement value, required and used only
84
+ * when type is "custom"
79
85
  * @returns {string}
80
86
  */
81
- function generatePseudonym(field, value, type, secret) {
87
+ function generatePseudonym(field, value, type, secret, customValue) {
82
88
  const generator = GENERATORS[type];
83
89
  if (!generator) {
84
90
  throw new Error(`Unknown pseudonym type "${type}"`);
85
91
  }
86
- return generator(field, value, secret);
92
+ return generator(field, value, secret, customValue);
87
93
  }
88
94
 
89
95
  module.exports = { generatePseudonym, PSEUDONYM_TYPES };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cap-mcp-guard",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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>",