cap-mcp-guard 0.3.1 → 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
 
@@ -96,11 +96,13 @@ function registerCapMcpGuard(cds, options = {}) {
96
96
  const { onDecision: userOnDecision, otel } = options;
97
97
  const audit = options.audit !== undefined ? options.audit : policyDefinition.audit;
98
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.
99
101
  const pseudonymSecret = options.pseudonymSecret || process.env.CAP_MCP_GUARD_PSEUDONYM_SECRET;
100
- const usesPseudonymize = Object.values(policyDefinition.entities).some(
101
- (entityConfig) => entityConfig.pseudonymize && entityConfig.pseudonymize.length > 0
102
+ const needsPseudonymSecret = Object.values(policyDefinition.entities).some((entityConfig) =>
103
+ (entityConfig.pseudonymize || []).some((entry) => entry.type !== 'custom')
102
104
  );
103
- if (usesPseudonymize && !pseudonymSecret) {
105
+ if (needsPseudonymSecret && !pseudonymSecret) {
104
106
  throw new Error(
105
107
  'CAP_MCP_GUARD_PSEUDONYM_SECRET env var (or options.pseudonymSecret) is required when "pseudonymize" is configured'
106
108
  );
@@ -91,9 +91,9 @@ 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
  });
@@ -45,7 +45,7 @@ function validatePseudonymizeEntry(entityName, entry) {
45
45
  }
46
46
 
47
47
  if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
48
- const { field, type = 'opaque' } = entry;
48
+ const { field, type = 'opaque', value } = entry;
49
49
 
50
50
  if (typeof field !== 'string' || !field) {
51
51
  throw new Error(`entities.${entityName}.pseudonymize entries must have a "field" string`);
@@ -55,6 +55,15 @@ function validatePseudonymizeEntry(entityName, entry) {
55
55
  `entities.${entityName}.pseudonymize.${field}.type must be one of ${PSEUDONYM_TYPES.join(', ')} (got ${JSON.stringify(type)})`
56
56
  );
57
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
+ }
58
67
  return { field, type };
59
68
  }
60
69
 
@@ -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.1",
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>",