palaryn 0.5.11 → 0.6.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.
Files changed (113) hide show
  1. package/dist/src/billing/plan-enforcer.d.ts.map +1 -1
  2. package/dist/src/billing/plan-enforcer.js +0 -2
  3. package/dist/src/billing/plan-enforcer.js.map +1 -1
  4. package/dist/src/dlp/circuit-breaker.d.ts +44 -0
  5. package/dist/src/dlp/circuit-breaker.d.ts.map +1 -0
  6. package/dist/src/dlp/circuit-breaker.js +69 -0
  7. package/dist/src/dlp/circuit-breaker.js.map +1 -0
  8. package/dist/src/dlp/deberta-backend.d.ts +2 -0
  9. package/dist/src/dlp/deberta-backend.d.ts.map +1 -1
  10. package/dist/src/dlp/deberta-backend.js +21 -3
  11. package/dist/src/dlp/deberta-backend.js.map +1 -1
  12. package/dist/src/dlp/index.d.ts +2 -0
  13. package/dist/src/dlp/index.d.ts.map +1 -1
  14. package/dist/src/dlp/index.js +5 -1
  15. package/dist/src/dlp/index.js.map +1 -1
  16. package/dist/src/dlp/llm-classifier.d.ts +2 -0
  17. package/dist/src/dlp/llm-classifier.d.ts.map +1 -1
  18. package/dist/src/dlp/llm-classifier.js +8 -0
  19. package/dist/src/dlp/llm-classifier.js.map +1 -1
  20. package/dist/src/dlp/multipart-extractor.d.ts +20 -0
  21. package/dist/src/dlp/multipart-extractor.d.ts.map +1 -0
  22. package/dist/src/dlp/multipart-extractor.js +60 -0
  23. package/dist/src/dlp/multipart-extractor.js.map +1 -0
  24. package/dist/src/dlp/navigation-instruction-backend.d.ts +6 -0
  25. package/dist/src/dlp/navigation-instruction-backend.d.ts.map +1 -0
  26. package/dist/src/dlp/navigation-instruction-backend.js +286 -0
  27. package/dist/src/dlp/navigation-instruction-backend.js.map +1 -0
  28. package/dist/src/dlp/nemo-backend.d.ts +2 -0
  29. package/dist/src/dlp/nemo-backend.d.ts.map +1 -1
  30. package/dist/src/dlp/nemo-backend.js +8 -0
  31. package/dist/src/dlp/nemo-backend.js.map +1 -1
  32. package/dist/src/dlp/prompt-injection-patterns.d.ts.map +1 -1
  33. package/dist/src/dlp/prompt-injection-patterns.js +36 -0
  34. package/dist/src/dlp/prompt-injection-patterns.js.map +1 -1
  35. package/dist/src/dlp/text-normalizer.d.ts +2 -15
  36. package/dist/src/dlp/text-normalizer.d.ts.map +1 -1
  37. package/dist/src/dlp/text-normalizer.js +34 -7
  38. package/dist/src/dlp/text-normalizer.js.map +1 -1
  39. package/dist/src/dlp/tool-patterns.d.ts +10 -0
  40. package/dist/src/dlp/tool-patterns.d.ts.map +1 -1
  41. package/dist/src/dlp/tool-patterns.js +40 -1
  42. package/dist/src/dlp/tool-patterns.js.map +1 -1
  43. package/dist/src/executor/filesystem-executor.d.ts +5 -5
  44. package/dist/src/executor/filesystem-executor.d.ts.map +1 -1
  45. package/dist/src/executor/filesystem-executor.js +43 -0
  46. package/dist/src/executor/filesystem-executor.js.map +1 -1
  47. package/dist/src/metrics/collector.d.ts +5 -0
  48. package/dist/src/metrics/collector.d.ts.map +1 -1
  49. package/dist/src/metrics/collector.js +14 -0
  50. package/dist/src/metrics/collector.js.map +1 -1
  51. package/dist/src/policy/engine.d.ts.map +1 -1
  52. package/dist/src/policy/engine.js +39 -3
  53. package/dist/src/policy/engine.js.map +1 -1
  54. package/dist/src/policy/opa-engine.d.ts.map +1 -1
  55. package/dist/src/policy/opa-engine.js +2 -1
  56. package/dist/src/policy/opa-engine.js.map +1 -1
  57. package/dist/src/server/app.d.ts.map +1 -1
  58. package/dist/src/server/app.js +17 -9
  59. package/dist/src/server/app.js.map +1 -1
  60. package/dist/src/server/gateway.d.ts +4 -0
  61. package/dist/src/server/gateway.d.ts.map +1 -1
  62. package/dist/src/server/gateway.js +146 -4
  63. package/dist/src/server/gateway.js.map +1 -1
  64. package/dist/src/types/config.d.ts +9 -0
  65. package/dist/src/types/config.d.ts.map +1 -1
  66. package/dist/src/types/policy.d.ts +4 -0
  67. package/dist/src/types/policy.d.ts.map +1 -1
  68. package/dist/src/types/tool-call.d.ts +4 -0
  69. package/dist/src/types/tool-call.d.ts.map +1 -1
  70. package/dist/tests/integration/navigation-chain.test.d.ts +9 -0
  71. package/dist/tests/integration/navigation-chain.test.d.ts.map +1 -0
  72. package/dist/tests/integration/navigation-chain.test.js +474 -0
  73. package/dist/tests/integration/navigation-chain.test.js.map +1 -0
  74. package/dist/tests/unit/adversarial-pipeline.test.js +173 -15
  75. package/dist/tests/unit/adversarial-pipeline.test.js.map +1 -1
  76. package/dist/tests/unit/cli.test.js +3 -7
  77. package/dist/tests/unit/cli.test.js.map +1 -1
  78. package/dist/tests/unit/filesystem-executor.test.js +88 -0
  79. package/dist/tests/unit/filesystem-executor.test.js.map +1 -1
  80. package/dist/tests/unit/multipart-extractor.test.d.ts +2 -0
  81. package/dist/tests/unit/multipart-extractor.test.d.ts.map +1 -0
  82. package/dist/tests/unit/multipart-extractor.test.js +118 -0
  83. package/dist/tests/unit/multipart-extractor.test.js.map +1 -0
  84. package/dist/tests/unit/navigation-instruction-backend.test.d.ts +8 -0
  85. package/dist/tests/unit/navigation-instruction-backend.test.d.ts.map +1 -0
  86. package/dist/tests/unit/navigation-instruction-backend.test.js +561 -0
  87. package/dist/tests/unit/navigation-instruction-backend.test.js.map +1 -0
  88. package/dist/tests/unit/policy-engine.test.js +314 -1
  89. package/dist/tests/unit/policy-engine.test.js.map +1 -1
  90. package/dist/tests/unit/prompt-injection-backend.test.js +1 -1
  91. package/dist/tests/unit/prompt-injection-backend.test.js.map +1 -1
  92. package/package.json +1 -1
  93. package/policy-packs/default.yaml +76 -0
  94. package/src/billing/plan-enforcer.ts +0 -2
  95. package/src/dlp/circuit-breaker.ts +83 -0
  96. package/src/dlp/deberta-backend.ts +21 -3
  97. package/src/dlp/index.ts +2 -0
  98. package/src/dlp/llm-classifier.ts +10 -0
  99. package/src/dlp/multipart-extractor.ts +66 -0
  100. package/src/dlp/navigation-instruction-backend.ts +309 -0
  101. package/src/dlp/nemo-backend.ts +10 -0
  102. package/src/dlp/prompt-injection-patterns.ts +37 -0
  103. package/src/dlp/text-normalizer.ts +36 -7
  104. package/src/dlp/tool-patterns.ts +40 -0
  105. package/src/executor/filesystem-executor.ts +51 -0
  106. package/src/metrics/collector.ts +17 -0
  107. package/src/policy/engine.ts +39 -3
  108. package/src/policy/opa-engine.ts +2 -1
  109. package/src/server/app.ts +19 -10
  110. package/src/server/gateway.ts +155 -4
  111. package/src/types/config.ts +9 -0
  112. package/src/types/policy.ts +5 -0
  113. package/src/types/tool-call.ts +4 -0
@@ -1,6 +1,9 @@
1
1
  import * as fs from 'fs';
2
2
  import * as net from 'net';
3
3
  import * as yaml from 'js-yaml';
4
+ import { logger } from '../server/logger';
5
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
6
+ const punycode = require('punycode/');
4
7
  import { ToolCall } from '../types/tool-call';
5
8
  import {
6
9
  PolicyDecision,
@@ -353,13 +356,13 @@ export class PolicyEngine {
353
356
  }
354
357
  // ReDoS protection: block patterns with nested quantifiers or excessive length
355
358
  if (!PolicyEngine.isSafeRegex(pattern)) {
356
- console.warn(`PolicyEngine: rejecting potentially unsafe regex pattern (ReDoS risk): "${pattern.slice(0, 100)}..."`);
359
+ logger.warn('Rejecting potentially unsafe regex pattern (ReDoS risk)', { component: 'policy-engine', pattern: pattern.slice(0, 100) });
357
360
  return null;
358
361
  }
359
362
  try {
360
363
  re = new RegExp(pattern);
361
364
  } catch (err) {
362
- console.warn(`PolicyEngine: invalid regex pattern "${pattern}": ${err instanceof Error ? err.message : String(err)}`);
365
+ logger.warn('Invalid regex pattern', { component: 'policy-engine', pattern, error: err instanceof Error ? err.message : String(err) });
363
366
  return null;
364
367
  }
365
368
  // Evict oldest entry if at capacity
@@ -775,6 +778,36 @@ export class PolicyEngine {
775
778
  if (!conditions.provider_tool_types.includes(toolType as string)) return false;
776
779
  }
777
780
 
781
+ // --- Referrer/provenance conditions ---
782
+ if (conditions.has_referrer !== undefined) {
783
+ const hasReferrer = !!toolCall.context?.referrer_url;
784
+ if (conditions.has_referrer !== hasReferrer) {
785
+ return false;
786
+ }
787
+ }
788
+
789
+ if (conditions.referrer_domains && conditions.referrer_domains.length > 0) {
790
+ const referrerUrl = toolCall.context?.referrer_url;
791
+ if (!referrerUrl) {
792
+ // Rule requires specific referrer domain but no referrer is present — no match.
793
+ return false;
794
+ }
795
+ const referrerDomain = this.extractDomain(referrerUrl);
796
+ if (!referrerDomain || !this.isDomainInList(referrerDomain, conditions.referrer_domains)) {
797
+ return false;
798
+ }
799
+ }
800
+
801
+ if (conditions.referrer_domains_blocklist && conditions.referrer_domains_blocklist.length > 0) {
802
+ const referrerUrl = toolCall.context?.referrer_url;
803
+ if (referrerUrl) {
804
+ const referrerDomain = this.extractDomain(referrerUrl);
805
+ if (referrerDomain && this.isDomainInList(referrerDomain, conditions.referrer_domains_blocklist)) {
806
+ return false;
807
+ }
808
+ }
809
+ }
810
+
778
811
  // --- DLP conditions (only evaluated when dlpContext is provided) ---
779
812
  if (conditions.dlp_detected !== undefined) {
780
813
  if (!dlpContext) return false; // DLP conditions require DLP context
@@ -812,7 +845,10 @@ export class PolicyEngine {
812
845
  private extractDomain(url: string): string | null {
813
846
  try {
814
847
  const parsed = new URL(url);
815
- return parsed.hostname.toLowerCase();
848
+ let hostname = parsed.hostname.toLowerCase();
849
+ // Decode Punycode (xn--) domains to Unicode for consistent matching
850
+ try { hostname = punycode.toUnicode(hostname); } catch { /* leave as-is */ }
851
+ return hostname;
816
852
  } catch {
817
853
  return null;
818
854
  }
@@ -3,6 +3,7 @@ import * as https from 'https';
3
3
  import { URL } from 'url';
4
4
  import { ToolCall } from '../types/tool-call';
5
5
  import { PolicyDecision, PolicyEvalResult, PolicyTransformation } from '../types/policy';
6
+ import { logger } from '../server/logger';
6
7
  import { OPAConfig } from '../types/config';
7
8
 
8
9
  /**
@@ -79,7 +80,7 @@ export class OPAEngine {
79
80
  throw new Error(`OPA server URL must not use private/reserved IP address in production: "${hostname}"`);
80
81
  }
81
82
  // In development, allow but warn
82
- console.warn(`[OPAEngine] WARNING: OPA server URL points to private address "${hostname}". This would be blocked in production.`);
83
+ logger.warn('OPA server URL points to private address blocked in production', { component: 'opa-engine', hostname });
83
84
  }
84
85
 
85
86
  // Normalize: remove trailing slashes for consistency
package/src/server/app.ts CHANGED
@@ -43,7 +43,7 @@ import { StripeClient, WebhookHandler, createWebhookRouter, createBillingRouter,
43
43
  import { createPlanEnforcerMiddleware } from '../billing/plan-enforcer';
44
44
  import { FilePersistedStores } from '../storage/file-persistence';
45
45
  import { hashPassword } from '../auth/password';
46
- import { log as devLog } from './logger';
46
+ import { log as devLog, logger } from './logger';
47
47
 
48
48
  const MAX_TRACKED_IPS = 10000;
49
49
 
@@ -262,6 +262,12 @@ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<Sa
262
262
  gateway.setStores({ policyStore: saasStores.policyStore });
263
263
  }
264
264
 
265
+ // Inject workspace store for plan enforcement inside the gateway pipeline
266
+ // (covers MCP bridge and proxy entry points, not just Express middleware)
267
+ if (saasStores.workspaceStore) {
268
+ gateway.setStores({ workspaceStore: saasStores.workspaceStore });
269
+ }
270
+
265
271
  // Register noop executors for non-HTTP tools (e.g. pre-flight validation from Android)
266
272
  gateway.registerExecutor('web_search', new NoopExecutor({ body: { validated: true, tool: 'web_search' } }));
267
273
  gateway.registerExecutor('chat.completion', new NoopExecutor({ body: { validated: true, tool: 'chat.completion' } }));
@@ -500,10 +506,13 @@ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<Sa
500
506
  const rbacPolicyWrite = createRBACMiddleware(config.auth, 'policy:write');
501
507
 
502
508
  // Admin routes (require auth + admin:full permission)
503
- // Auth + RBAC middleware mounted directly with the router to prevent route decoupling
504
- const adminRouter = createAdminRouter(gateway, config);
509
+ // Deprecated: use React SPA frontend instead (FRONTEND_ENABLED=true).
510
+ // Server-rendered admin is kept as fallback when frontend is disabled.
505
511
  const rbacAdmin = createRBACMiddleware(config.auth, 'admin:full');
506
- app.use('/admin', authMiddleware, rbacAdmin, adminRouter);
512
+ if (!config.frontend?.enabled) {
513
+ const adminRouter = createAdminRouter(gateway, config);
514
+ app.use('/admin', authMiddleware, rbacAdmin, adminRouter);
515
+ }
507
516
 
508
517
  // Plan enforcer middleware — blocks calls when workspace exceeds plan limits
509
518
  const planEnforcerMiddleware = createPlanEnforcerMiddleware({
@@ -568,13 +577,13 @@ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<Sa
568
577
  }
569
578
  // S3: Never leak raw error messages to clients (may contain internal paths, IPs, secrets)
570
579
  if (result.error) {
571
- console.error('[tool-execute] Gateway error:', result.error);
580
+ logger.error('Gateway error', { component: 'tool-execute', error: result.error });
572
581
  result.error = 'Tool execution failed';
573
582
  }
574
583
  res.status(httpStatus).json(result);
575
584
  } catch (err) {
576
585
  const errorMessage = err instanceof Error ? err.message : 'Unknown error';
577
- console.error('[tool-execute] Execution error:', errorMessage);
586
+ logger.error('Execution error', { component: 'tool-execute', error: errorMessage });
578
587
  sendError(res, 500, ErrorCode.TOOL_EXECUTION_ERROR, 'Tool execution failed');
579
588
  }
580
589
  });
@@ -635,13 +644,13 @@ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<Sa
635
644
  headers: proxyResult.headers,
636
645
  }, pre);
637
646
  } catch (postErr) {
638
- console.error('[post-execute] Failed:', postErr instanceof Error ? postErr.message : postErr);
647
+ logger.error('Post-execute failed', { component: 'stream-proxy', error: postErr instanceof Error ? postErr.message : postErr });
639
648
  // Don't fail the response — it's already sent for streaming
640
649
  }
641
650
 
642
651
  } catch (err) {
643
652
  const errorMessage = err instanceof Error ? err.message : 'Unknown error';
644
- console.error('[stream-proxy] Execution error:', errorMessage);
653
+ logger.error('Execution error', { component: 'stream-proxy', error: errorMessage });
645
654
  if (!res.headersSent) {
646
655
  sendError(res, 502, ErrorCode.TOOL_EXECUTION_ERROR, 'Stream proxy failed');
647
656
  } else {
@@ -684,7 +693,7 @@ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<Sa
684
693
  }
685
694
  } catch (err) {
686
695
  const errorMessage = err instanceof Error ? err.message : 'Unknown error';
687
- console.error('[tool-approve] Error:', errorMessage);
696
+ logger.error('Approval error', { component: 'tool-approve', error: errorMessage });
688
697
  sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Approval processing failed');
689
698
  }
690
699
  });
@@ -751,7 +760,7 @@ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<Sa
751
760
  res.json({ status: 'ok' });
752
761
  } catch (err) {
753
762
  const errorMessage = err instanceof Error ? err.message : 'Unknown error';
754
- console.error('[usage-report] Error:', errorMessage);
763
+ logger.error('Usage report error', { component: 'usage-report', error: errorMessage });
755
764
  sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Usage reporting failed');
756
765
  }
757
766
  });
@@ -14,6 +14,9 @@ import { HeuristicScorerBackend } from '../dlp/heuristic-scorer';
14
14
  import { scorePromptInjection } from '../dlp/heuristic-scorer';
15
15
  import { TruffleHogBackend } from '../dlp/trufflehog-backend';
16
16
  import { ExfiltrationDetectionBackend } from '../dlp/exfiltration-backend';
17
+ import { NavigationInstructionBackend } from '../dlp/navigation-instruction-backend';
18
+ import { SensitiveFileBackend } from '../dlp/tool-patterns';
19
+ import { extractMultipartTextParts } from '../dlp/multipart-extractor';
17
20
  import { NemoGuardrailsBackend } from '../dlp/nemo-backend';
18
21
  import { DeBERTaBackend } from '../dlp/deberta-backend';
19
22
  import { BudgetManager, CostRecord } from '../budget/manager';
@@ -37,6 +40,9 @@ import { GatewayMetrics } from '../metrics';
37
40
  import { GatewayTracer } from '../tracing';
38
41
  import { AnomalyDetector } from '../anomaly';
39
42
  import { LlmPromptInjectionClassifier } from '../dlp/llm-classifier';
43
+ import { PlanEnforcer } from '../billing/plan-enforcer';
44
+ import { WorkspaceStore } from '../storage/interfaces';
45
+ import { PlanTier } from '../types/subscription';
40
46
  import { log as devLog, logger } from './logger';
41
47
 
42
48
  export interface PreExecuteResult {
@@ -169,6 +175,7 @@ export class Gateway {
169
175
  * the in-flight result instead of executing a second time.
170
176
  */
171
177
  private inFlightCalls = new Map<string, InFlightEntry>();
178
+ private workspaceStore?: WorkspaceStore;
172
179
 
173
180
  constructor(config: GatewayConfig, metrics?: GatewayMetrics, tracer?: GatewayTracer) {
174
181
  this.config = config;
@@ -183,6 +190,8 @@ export class Gateway {
183
190
  }));
184
191
  dlpBackends.push(new HeuristicScorerBackend());
185
192
  dlpBackends.push(new ExfiltrationDetectionBackend());
193
+ dlpBackends.push(new NavigationInstructionBackend());
194
+ dlpBackends.push(new SensitiveFileBackend());
186
195
  }
187
196
  if (config.dlp.deberta?.enabled) {
188
197
  dlpBackends.push(new DeBERTaBackend({
@@ -263,7 +272,7 @@ export class Gateway {
263
272
  // A1: Periodic cleanup of stale inFlightCalls entries (every 60s, remove entries older than 5min)
264
273
  this.inFlightCleanupInterval = setInterval(() => {
265
274
  const now = Date.now();
266
- const MAX_AGE_MS = 5 * 60 * 1000;
275
+ const MAX_AGE_MS = this.config.idempotency_cache_ttl_ms ?? 300_000;
267
276
  for (const [key, entry] of this.inFlightCalls) {
268
277
  if (now - entry.createdAt > MAX_AGE_MS) {
269
278
  clearTimeout(entry.timeout);
@@ -368,6 +377,38 @@ export class Gateway {
368
377
  }
369
378
  devLog.anomaly(anomalyAlerts.length, false);
370
379
 
380
+ // Plan enforcement — block calls when workspace exceeds plan limits.
381
+ // Covers ALL entry points (REST, MCP bridge, MCP HTTP, proxy).
382
+ if (this.workspaceStore && toolCall.workspace_id) {
383
+ stepStart = Date.now();
384
+ const workspace = this.workspaceStore.getById(toolCall.workspace_id);
385
+ if (workspace) {
386
+ const plan = (workspace.plan || 'free') as PlanTier;
387
+ const now = new Date();
388
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
389
+ const events = this.auditLogger.getAllEvents();
390
+ const monthlyCount = events.filter(
391
+ e => e.workspace_id === toolCall.workspace_id
392
+ && e.event_type === 'TOOL_CALL_RECEIVED'
393
+ && e.timestamp >= monthStart
394
+ ).length;
395
+ const planResult = PlanEnforcer.enforce(plan, monthlyCount);
396
+ if (!planResult.allowed) {
397
+ const durationSec = (Date.now() - startTime) / 1000;
398
+ this.metrics?.recordRequest('blocked', toolCall.tool.name, toolCall.tool.capability, durationSec);
399
+ const result = this.buildResult(toolCall, 'blocked', {
400
+ decision: 'deny',
401
+ rule_id: 'plan_limit',
402
+ rule_name: 'Plan limit',
403
+ reasons: [planResult.reason || 'Plan limit exceeded'],
404
+ }, startTime, undefined, planResult.reason);
405
+ devLog.pipelineEnd('blocked', Date.now() - startTime);
406
+ return { allowed: false, result, stepTimings, startTime };
407
+ }
408
+ }
409
+ stepTimings.plan_enforcement = Date.now() - stepStart;
410
+ }
411
+
371
412
  // DLP scan on full toolCall (args + context + actor fields)
372
413
  // Runs BEFORE policy evaluation so secrets are always detected regardless of policy outcome
373
414
  stepStart = Date.now();
@@ -628,6 +669,8 @@ export class Gateway {
628
669
  stepTimings.policy = Date.now() - stepStart;
629
670
  this.auditLogger.logPolicyDecided(toolCall, policyResult.decision, policyResult.rule_id, policyResult.reasons);
630
671
  this.metrics?.recordPolicyDecision(policyResult.decision, policyResult.rule_id || 'unknown');
672
+ const policyEngine_ = usedWorkspacePolicy ? 'workspace' : (this.opaEngine ? 'opa' : 'yaml');
673
+ this.metrics?.recordPolicyEvaluation(policyEngine_, stepTimings.policy / 1000);
631
674
  devLog.policy(policyResult.decision, policyResult.rule_id, policyResult.reasons);
632
675
 
633
676
  // Policy: deny
@@ -741,12 +784,51 @@ export class Gateway {
741
784
  this.anomalyDetector?.recordResult(toolCall, executionDuration, false);
742
785
  this.anomalyDetector?.analyzeResult(toolCall, executionDuration, false);
743
786
 
787
+ // Content-type inspection: determine if output can be meaningfully text-scanned
788
+ const responseContentType = (output.headers?.['content-type'] || '').toLowerCase();
789
+ const OPAQUE_PREFIXES = ['image/', 'audio/', 'video/', 'application/pdf', 'application/zip',
790
+ 'application/gzip', 'application/octet-stream', 'application/wasm', 'font/'];
791
+ const isOpaqueContent = OPAQUE_PREFIXES.some(p => responseContentType.startsWith(p))
792
+ && !responseContentType.includes('image/svg+xml'); // SVGs are text-based XML
793
+
794
+ // For multipart responses, extract text parts for scanning
795
+ let multipartTextParts: string[] | undefined;
796
+ if (responseContentType.includes('multipart/') && typeof output.body === 'string') {
797
+ const boundaryMatch = responseContentType.match(/boundary=([^\s;]+)/);
798
+ if (boundaryMatch) {
799
+ multipartTextParts = extractMultipartTextParts(output.body, boundaryMatch[1]);
800
+ }
801
+ }
802
+
744
803
  // DLP scan on output
745
804
  let stepStart = Date.now();
746
805
  let outputDlp: { detected: string[]; redactions: any[]; severity: DLPSeverity } = { detected: [], redactions: [], severity: 'low' };
747
806
  if (this.config.dlp.scan_output && output.body) {
748
807
  outputDlp = childSpanWithAttrs(otel, SPAN.DLP_OUTPUT, (s) => {
749
- const result = this.dlpScanner.scan(output, 'output');
808
+ let result;
809
+ if (isOpaqueContent) {
810
+ // Binary content cannot be text-scanned — flag it as unscannable
811
+ result = {
812
+ detected: ['opaque_content_type_unscannable'],
813
+ redactions: [],
814
+ severity: 'medium' as DLPSeverity,
815
+ };
816
+ devLog.pipelineStep('⚠️', 'DLP_OPAQUE_CONTENT',
817
+ `Response content-type ${responseContentType} cannot be text-scanned`);
818
+ } else if (multipartTextParts && multipartTextParts.length > 0) {
819
+ // Scan extracted text parts from multipart response
820
+ result = this.dlpScanner.scan(output, 'output');
821
+ for (const part of multipartTextParts) {
822
+ const partReport = this.dlpScanner.scan({ content: part }, 'output.multipart');
823
+ for (const det of partReport.detected) {
824
+ if (!result.detected.includes(det)) result.detected.push(det);
825
+ }
826
+ result.redactions.push(...partReport.redactions);
827
+ result.severity = this.maxSeverity(result.severity, partReport.severity);
828
+ }
829
+ } else {
830
+ result = this.dlpScanner.scan(output, 'output');
831
+ }
750
832
  s.setAttribute(ATTR.DLP_DETECTION_COUNT, result.detected.length);
751
833
  s.setAttribute(ATTR.DLP_SEVERITY, result.severity);
752
834
  if (result.detected.length > 0) s.setAttribute(ATTR.DLP_TYPES, result.detected.join(','));
@@ -847,6 +929,70 @@ export class Gateway {
847
929
  }
848
930
  }
849
931
 
932
+ // Navigation instruction handling (runs after prompt injection output check)
933
+ const navAction = this.config.dlp.navigation_instruction_action || 'log';
934
+ if (navAction !== 'log' && outputDlp.detected.length > 0) {
935
+ const navDetections = outputDlp.detected.filter((d: string) =>
936
+ d.startsWith('navigation_instruction_')
937
+ );
938
+ if (navDetections.length > 0) {
939
+ if (navAction === 'block') {
940
+ // Release budget reservation since we're blocking
941
+ if (reservationKey) {
942
+ this.budgetManager.commitReservation(reservationKey, 0);
943
+ }
944
+ devLog.pipelineStep('🧭', 'NAVIGATION_INSTRUCTION_BLOCK',
945
+ `Blocked output: ${navDetections.join(', ')}`);
946
+ const durationSec = (Date.now() - startTime) / 1000;
947
+ this.metrics?.recordRequest('blocked', toolCall.tool.name, toolCall.tool.capability, durationSec);
948
+ const defaultPolicy = policyResult || { decision: 'allow' as const, rule_id: 'passthrough', rule_name: 'Passthrough', reasons: [] };
949
+ const argsDlpSafe = argsDlp || { detected: [], redactions: [], severity: 'low' as DLPSeverity };
950
+ const mergedDlp = {
951
+ detected: [...new Set([...argsDlpSafe.detected, ...outputDlp.detected])],
952
+ redactions: [...argsDlpSafe.redactions, ...outputDlp.redactions],
953
+ severity: this.maxSeverity(argsDlpSafe.severity, outputDlp.severity),
954
+ };
955
+ const result = this.buildResult(toolCall, 'blocked', {
956
+ decision: 'deny',
957
+ rule_id: 'navigation_instruction_output_block',
958
+ rule_name: 'Navigation instructions detected in output',
959
+ reasons: [`Navigation instructions detected in output: ${navDetections.join(', ')}`],
960
+ }, startTime, undefined,
961
+ `Blocked by navigation instruction detection in output: ${navDetections.join(', ')}`,
962
+ undefined, mergedDlp);
963
+ this.auditLogger.logToolResultReturned(toolCall, 'blocked', Date.now() - startTime, { navigation_instruction_blocked: true, detections: navDetections });
964
+ devLog.pipelineEnd('blocked', Date.now() - startTime);
965
+ return result;
966
+ } else if (navAction === 'strip' && output.body) {
967
+ // Re-scan the body string with NavigationInstructionBackend to get
968
+ // actual match positions (the DLPReport only carries pattern names).
969
+ let bodyStr = typeof output.body === 'string' ? output.body : JSON.stringify(output.body);
970
+ const navBackend = new NavigationInstructionBackend();
971
+ const matches = navBackend.scanString(bodyStr);
972
+
973
+ if (matches.length > 0) {
974
+ // Sort by start position descending so splicing from the end
975
+ // doesn't invalidate earlier indices.
976
+ matches.sort((a, b) => b.start - a.start);
977
+ for (const m of matches) {
978
+ const before = bodyStr.slice(0, m.start);
979
+ const after = bodyStr.slice(m.end);
980
+ bodyStr = before + '[NAVIGATION_INSTRUCTION_REDACTED]' + after;
981
+ }
982
+ }
983
+
984
+ // Re-parse if original was JSON
985
+ if (typeof output.body !== 'string') {
986
+ try { output.body = JSON.parse(bodyStr); } catch { output.body = bodyStr; }
987
+ } else {
988
+ output.body = bodyStr;
989
+ }
990
+ devLog.pipelineStep('🧭', 'NAVIGATION_INSTRUCTION_STRIP',
991
+ `Stripped ${matches.length} navigation instruction(s) from output`);
992
+ }
993
+ }
994
+ }
995
+
850
996
  // Extract usage data from response
851
997
  const headerUsage = this.usageExtractor.extractFromHeaders(output.headers);
852
998
  const bodyUsage = this.usageExtractor.extractFromBody(output.body);
@@ -1084,8 +1230,8 @@ export class Gateway {
1084
1230
  // Run post-execution pipeline (DLP output, usage, budget recording, metrics, audit)
1085
1231
  const result = await this.postExecute(toolCall, output, pre, otel);
1086
1232
 
1087
- // Cache result for idempotency (5 minute TTL) using cache key with body hash
1088
- this.idempotencyStore.set(cacheKey, result, 300000);
1233
+ // Cache result for idempotency using cache key with body hash
1234
+ this.idempotencyStore.set(cacheKey, result, this.config.idempotency_cache_ttl_ms ?? 300_000);
1089
1235
  if (this.idempotencyStore.flush) await this.idempotencyStore.flush();
1090
1236
 
1091
1237
  return result;
@@ -1260,7 +1406,11 @@ export class Gateway {
1260
1406
  policyStore?: PolicyStore;
1261
1407
  rateLimitConfigStore?: RateLimitConfigStore;
1262
1408
  budgetConfigStore?: BudgetConfigStore;
1409
+ workspaceStore?: WorkspaceStore;
1263
1410
  }): void {
1411
+ if (stores.workspaceStore) {
1412
+ this.workspaceStore = stores.workspaceStore;
1413
+ }
1264
1414
  if (stores.idempotencyStore) {
1265
1415
  this.idempotencyStore = stores.idempotencyStore;
1266
1416
  }
@@ -1305,6 +1455,7 @@ export class Gateway {
1305
1455
  getPolicyStore(): PolicyStore | undefined { return this.policyStore; }
1306
1456
  getRateLimitConfigStore(): RateLimitConfigStore | undefined { return this.rateLimitConfigStore; }
1307
1457
  getBudgetConfigStore(): BudgetConfigStore | undefined { return this.budgetConfigStore; }
1458
+ getWorkspaceStore(): WorkspaceStore | undefined { return this.workspaceStore; }
1308
1459
 
1309
1460
  /** Return the workspace-specific policy if one exists, otherwise the global policy. */
1310
1461
  getWorkspacePolicy(workspaceId: string): { policy: PolicyPack; is_custom: boolean } {
@@ -37,6 +37,8 @@ export interface GatewayConfig {
37
37
  mcp_oauth?: MCPOAuthConfig;
38
38
  /** Webhook alerting for governance events (DLP, budget, anomaly, policy denials) */
39
39
  alerting?: AlertingConfig;
40
+ /** Idempotency cache TTL in milliseconds. Default: 300000 (5 minutes). */
41
+ idempotency_cache_ttl_ms?: number;
40
42
  }
41
43
 
42
44
  export interface MCPOAuthConfig {
@@ -190,6 +192,13 @@ export interface DLPConfig {
190
192
  api_url: string;
191
193
  timeout_ms?: number;
192
194
  };
195
+ /**
196
+ * Action when navigation instructions are detected in output responses.
197
+ * - 'log' (default): detect and log, pass response through unchanged
198
+ * - 'strip': redact detected navigation instructions from the response body
199
+ * - 'block': block the entire response if navigation instructions are found
200
+ */
201
+ navigation_instruction_action?: 'log' | 'strip' | 'block';
193
202
  }
194
203
 
195
204
  export interface AuditConfig {
@@ -57,6 +57,11 @@ export interface PolicyConditions {
57
57
  dlp_severity?: string[]; // e.g., ['high', 'critical'] — match if DLP max severity is in this list
58
58
  dlp_detected?: boolean; // true = rule matches only when DLP detections exist
59
59
  dlp_pattern_names?: string[]; // match if any of these pattern names were detected
60
+
61
+ /** Referrer/provenance conditions — match based on context.referrer_url */
62
+ has_referrer?: boolean; // true = match only when referrer_url is present; false = only when absent
63
+ referrer_domains?: string[]; // allowlist: match only if referrer URL domain is in this list (supports glob, e.g. "*.github.com")
64
+ referrer_domains_blocklist?: string[]; // blocklist: match if referrer URL domain is in this list
60
65
  }
61
66
 
62
67
  export interface PolicyPack {
@@ -31,6 +31,10 @@ export interface Constraints {
31
31
  export interface CallContext {
32
32
  purpose?: string;
33
33
  labels?: string[];
34
+ /** URL that triggered this request (set by agent/SDK when following a link from a previous response). */
35
+ referrer_url?: string;
36
+ /** Tool call ID that produced the response containing the referrer URL. */
37
+ referrer_tool_call_id?: string;
34
38
  }
35
39
 
36
40
  export interface ToolCall {